# How Do I Run Grafana and Prometheus in a Cloud Sandbox?

> 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.

Source: https://sandboxer.com/guides/run-grafana-and-prometheus-in-a-sandbox. Updated 2026-10-27.

**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.

```text
mon/
├── compose.yaml
├── prometheus.yml
└── grafana/
    ├── provisioning/datasources/prometheus.yaml
    ├── provisioning/dashboards/files.yaml
    └── dashboards/sandbox.json
```

```yaml
# 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:
```

```yaml
# 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](https://prometheus.io/docs/prometheus/latest/installation/), [node_exporter](https://github.com/prometheus/node_exporter#docker) (`pid: host` and the read-only root mount let it report the sandbox VM rather than its own container) and [Grafana](https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/).

### 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](https://grafana.com/docs/grafana/latest/administration/provisioning/)).

```yaml
# mon/grafana/provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
  - name: Prometheus
    uid: prom
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
```

```yaml
# mon/grafana/provisioning/dashboards/files.yaml
apiVersion: 1
providers:
  - name: files
    folder: Sandbox
    type: file
    options:
      path: /var/lib/grafana/dashboards
```

`dashboards/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.

```bash
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 Grafana
```

```text
https://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:

```bash
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"
```

```text
 Container mon-prometheus-1 Started
 Container mon-grafana-1 Starting
 Container mon-node-exporter-1 Started
 Container mon-grafana-1 Started

real	0m10.605s
```

Read 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`](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#root_url).

```bash
sbx exec $id "curl -s localhost:3000/api/health; cd mon && docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'"
```

```text
{
  "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/tcp
```

## 3. 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:

```bash
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"
```

```text
Sandbox node  (folder: Sandbox, url /d/sandbox-node/sandbox-node)
node        http://node-exporter:9100/metrics  up
prometheus  http://localhost:9090/metrics      up
```

The 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.

```bash
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker inspect -f '{{.Name}} pid={{.State.Pid}}' \$(docker ps -q)"
```

```text
ready  memoryRestored=true      # from the resume JSON
/mon-grafana-1 pid=887
/mon-node-exporter-1 pid=826
/mon-prometheus-1 pid=749
```

Same 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:

```text
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](https://prometheus.io/docs/prometheus/latest/querying/basics/#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`:

```text
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 seconds
```

```text
prometheus-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

- [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [Hosting](/docs/hosting)
