How Do I Run Grafana and Prometheus in a Cloud Sandbox?
Updated October 27, 2026 .md
Short answer: Put Prometheus, node_exporter and Grafana in one compose.yaml with a provisioned data source and dashboard, run docker compose up -d in a default Sandboxer sandbox, and host only Grafana's port 3000 with sbx host. Prometheus stays internal, the URL is private by default, and data and logins survive stop, resume and reboot.
At a glance#
Everything below ran on one default sandbox (4 vCPU, 8 GB RAM) on October 27, 2026, from a laptop in the US to a sandbox in eu-central. Single runs, so treat timings as rough.
| Check | What we saw |
|---|---|
docker compose up -d (3 images pulled, 1.07 GB) |
10.6 s |
Grafana /api/health |
"database": "ok", version 12.2.0, within 4 s of the container starting |
| Memory used by the stack | Grafana 101 MiB, Prometheus 28 MiB, node_exporter 8 MiB |
| Hosted URL without the token | 401, also for /api/health and the Live WebSocket |
| Grafana Live WebSocket with the cookie | Connected ("connect":{…"ping":25,"pong":true}) |
| Dashboard query, inside the sandbox | Median 5.3 ms (20 runs) |
| Same query through the hosted URL | Median 194 ms (20 runs, almost all of it the US to EU round trip) |
sbx stop --wait + sbx resume |
memoryRestored: true, same PIDs, Grafana session still valid |
| Reboot (cold boot) | All 3 containers back, metrics from before the reboot kept, session kept |
1. The stack#
Three services, two named volumes, and only Grafana publishes a port. Prometheus and node_exporter stay on the Compose network: Grafana reaches them by service name, and nothing outside the sandbox can.
mon/
├── compose.yaml
├── prometheus.yml
└── grafana/
├── provisioning/datasources/prometheus.yaml
├── provisioning/dashboards/files.yaml
└── dashboards/sandbox.json# mon/compose.yaml
services:
prometheus:
image: prom/prometheus:v3.5.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prom-data:/prometheus
restart: unless-stopped
node-exporter:
image: prom/node-exporter:v1.9.1
command:
- --path.rootfs=/host
pid: host
volumes:
- /:/host:ro,rslave
restart: unless-stopped
grafana:
image: grafana/grafana:12.2.0
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set it}
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3000}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
restart: unless-stopped
volumes:
prom-data:
grafana-data:# mon/prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]The images and flags follow each project's own Docker instructions: Prometheus, node_exporter (pid: host and the read-only root mount let it report the sandbox VM rather than its own container) and Grafana.
Provision the data source and a dashboard#
Clicking a data source together in the UI works, but files make the stack reproducible: copy the folder into a fresh sandbox and the dashboards are there on first start. Grafana reads these at boot (provisioning docs).
# mon/grafana/provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
- name: Prometheus
uid: prom
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true# mon/grafana/provisioning/dashboards/files.yaml
apiVersion: 1
providers:
- name: files
folder: Sandbox
type: file
options:
path: /var/lib/grafana/dashboardsdashboards/sandbox.json holds a dashboard with uid sandbox-node and four panels. The queries are the useful part:
| Panel | PromQL |
|---|---|
| CPU busy (%) | 100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))) |
| Memory used (GiB) | (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 2^30 |
| Scrape targets up | sum(up) |
| Root disk used (%) | 100 * (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) |
Each panel sets "datasource": { "type": "prometheus", "uid": "prom" }, matching the provisioned uid.
2. Start it#
Host the port first, so you know the URL to give Grafana as its root_url. A hosted port with nothing listening yet is fine.
sbx new --name monitoring --type default
id=sb_kspm9i99vr # the id that sbx new printed
sbx scp -r ./mon $id:/home/user/
sbx host $id 3000 --title Grafanahttps://fresh-spare-glade-3000.box.sandboxer.com?_token=<token>Generate the admin password inside the sandbox so it never passes through your shell history, then start the stack:
sbx exec $id --timeout 600 "cd mon && \
printf 'GRAFANA_ADMIN_PASSWORD=%s\nGRAFANA_ROOT_URL=https://fresh-spare-glade-3000.box.sandboxer.com/\n' \$(openssl rand -hex 16) > .env && \
chmod 600 .env && time docker compose up -d" Container mon-prometheus-1 Started
Container mon-grafana-1 Starting
Container mon-node-exporter-1 Started
Container mon-grafana-1 Started
real 0m10.605sRead the password with sbx exec $id "grep ^GRAFANA_ADMIN_PASSWORD= mon/.env" when you sign in. Leave the token out of GRAFANA_ROOT_URL. Grafana uses it to build absolute links (share links, alert notifications), and the Sandboxer cookie, not the URL, carries access after the first visit. See root_url.
sbx exec $id "curl -s localhost:3000/api/health; cd mon && docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'"{
"database": "ok",
"version": "12.2.0",
…
}
SERVICE STATUS PORTS
grafana Up 4 seconds 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp
node-exporter Up 4 seconds 9100/tcp
prometheus Up 4 seconds 9090/tcp3. Two locks on one URL#
The hosted URL is private, and Grafana has its own login behind it. What the proxy and Grafana returned:
| Request | Status |
|---|---|
/ or /api/health, no token |
401 from the proxy. Grafana never sees it |
The full URL with ?_token= |
302 to /, sets the __sbx_auth cookie |
/ with the cookie |
302 to /login (Grafana's own login) |
POST /login with the cookie |
{"message":"Logged in","redirectUrl":"/"}, sets grafana_session |
GET /api/user with Basic auth and the cookie |
200, "login": "admin" |
GET /api/user with Basic auth, no cookie |
401 from the proxy |
WebSocket /api/live/ws, no cookie |
401 |
WebSocket /api/live/ws with the cookie |
Connected, Grafana Live answered |
Two things follow. Grafana's Authorization header passes through the proxy untouched, so API scripts work, as long as they also send the __sbx_auth cookie. And Grafana Live, which pushes dashboard updates over a WebSocket, works through the hosted URL.
Logged in, the provisioned pieces were there:
curl -s -b jar.txt "$GRAFANA/api/search?type=dash-db"
curl -s -b jar.txt "$GRAFANA/api/datasources/proxy/uid/prom/api/v1/targets"Sandbox node (folder: Sandbox, url /d/sandbox-node/sandbox-node)
node http://node-exporter:9100/metrics up
prometheus http://localhost:9090/metrics upThe second call goes through Grafana's data source proxy. You get the Prometheus API from outside without hosting port 9090, and only after a Grafana login.
Which access mode to pick#
| Who needs the dashboards | Do this |
|---|---|
| Only you, or people you send the link to | Keep the default private URL. Two locks: token, then Grafana login |
| A script or CI job calling the Grafana API | Private URL. Open the token URL once with a cookie jar, then send the cookie plus Grafana Basic auth or a service account token |
| Prometheus queried from your laptop | Don't host 9090. Use the Grafana data source proxy as above |
| Anyone on the internet | sbx host $id 3000 --public. Grafana's login is then the only gate, so keep GF_USERS_ALLOW_SIGN_UP=false and a strong admin password |
4. How fast queries are#
We timed the CPU panel's query over a 1 hour range through POST /api/ds/query, 20 times each:
| Where the request came from | Min | Median | Max |
|---|---|---|---|
Inside the sandbox (localhost:3000) |
4.2 ms | 5.3 ms | 16.2 ms |
| A laptop in the US, through the hosted URL | 192 ms | 194 ms | 201 ms |
Grafana and Prometheus answer in about 5 ms. Through the URL, the rest is network distance to eu-central: a plain /api/health took 198 ms from the same laptop. If dashboards feel slow from far away, distance is the cause, not the stack.
5. What a stop looks like in your graphs#
We noted the PIDs, stopped the sandbox at 09:49:52 UTC, left it stopped for about two minutes, and resumed at 09:52:03.
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker inspect -f '{{.Name}} pid={{.State.Pid}}' \$(docker ps -q)"ready memoryRestored=true # from the resume JSON
/mon-grafana-1 pid=887
/mon-node-exporter-1 pid=826
/mon-prometheus-1 pid=749Same PIDs as before the stop, and the Grafana session cookie still worked. While the sandbox was stopped, the URL returned 404. It answered 200 right after resume, with no new sbx host call.
The interesting part is the data. Prometheus stopped scraping with everything else, so the raw samples have a hole:
raw samples 11 first 09:48:52 last 09:53:17
gap 09:49:52 -> 09:50:53 (61 s)
gap 09:50:53 -> 09:52:17 (84 s)One sample is stamped 09:50:53, while the sandbox was stopped. We saw this once, on October 27, 2026, and did not track down why. One possible reason: after a resume with memory, timers based on elapsed time don't count the stopped time, so Prometheus's scrape schedule may run off from the wall clock for a while. If exact timing matters, restart time-sensitive services after a resume or schedule by wall clock. Either way, the graph tells a different story than the samples:
| Query, 15 s step, 09:49 to 09:53:30 | What it showed |
|---|---|
up{job="node"} |
1 at every step, including the whole stop |
count_over_time(up{job="node"}[1m]) |
4 while running, fell to 1, no value at 09:52:00 and 09:52:15, then climbed back to 4 |
up stays at 1 because a paused Prometheus writes no staleness markers. A range query then reuses the last sample for up to 5 minutes, the default lookback (staleness). Any stop shorter than that draws as a flat line. To see stops on a dashboard, graph count_over_time(up{job="node"}[1m]). With a 15 s scrape interval, anything under 4 means missed scrapes. Stops longer than 5 minutes show as a plain gap in every panel.
6. Cold boot: what comes back#
A resume boots from disk instead of restoring memory when it lands on a different CPU type, or when you change size. To test that path, we created a dashboard through the API (it lives in Grafana's database, not in the provisioning files), then rebooted with sudo systemctl reboot:
6.50 22.01 # /proc/uptime: a fresh boot
NAMES STATUS
mon-grafana-1 Up 5 seconds
mon-node-exporter-1 Up 5 seconds
mon-prometheus-1 Up 5 secondsprometheus-1 | msg="WAL replay completed" … wal_replay_duration=9.060953ms
session after reboot: admin
dashboard: Made through the API
dashboard: Sandbox node
raw samples 16 first 09:48:52 last 09:54:52
gap 09:53:32 -> 09:54:07 (35 s)Docker is an enabled service, and restart: unless-stopped brought all three containers back. Prometheus replayed its write-ahead log from the prom-data volume, so samples from before the reboot are still queryable. The reboot cost a 35 s gap. Grafana kept the API-made dashboard and the login session, both in the grafana-data volume. Without those named volumes, a cold boot would keep the files on disk but start Grafana and Prometheus with empty data.
FAQ#
Which size should I use?#
default (4 vCPU, 8 GB) ran this stack with the three containers using under 140 MiB together. Size for whatever you are monitoring next to it. Moving to a smaller size isn't supported yet, so start on the size you plan to keep.
Where is the admin password if I lose it?#
In mon/.env inside the sandbox, readable only by user. Print it to your own terminal with sbx exec $id "grep ^GRAFANA_ADMIN_PASSWORD= mon/.env". It survives stop, resume and reboot with the rest of the disk. GF_SECURITY_ADMIN_PASSWORD only sets the password on Grafana's first start, so changing .env later doesn't change it.
Why not host Prometheus's port 9090 too?#
You can, but Prometheus has no login of its own. Behind a private URL the token is its only protection. Grafana's data source proxy already gives logged-in users the Prometheus API, so most setups need only port 3000.
Will the sandbox stop on its own and leave holes in my metrics?#
Yes, after one hour by default and after a period of inactivity, and nothing is scraped while it is stopped. For continuous collection, run sbx extend $id --hours 12 or sbx idle $id 0, and watch count_over_time(up[1m]) for gaps.
Do I need to re-host port 3000 after a stop?#
No. Hosted routes and tokens are kept across stop and resume, and the subdomain doesn't change. A fork gets a new subdomain and no routes, so host 3000 again there and update GRAFANA_ROOT_URL.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.