# How to Run Metabase in a Cloud Sandbox

> Run the official metabase/metabase Docker image in a Sandboxer microVM with a volume for its application database, then host port 3000 on a private URL. It was healthy about 15 seconds after the JVM started, used 1.3 to 1.8 GB of RAM, and answered Sample Database queries in 5 to 20 ms.

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

**Short answer:** Run the official metabase/metabase Docker image in a Sandboxer microVM with a volume for its application database, then host port 3000 on a private URL. It was healthy about 15 seconds after the JVM started, used 1.3 to 1.8 GB of RAM, and answered Sample Database queries in 5 to 20 ms.

## What we ran

Everything below ran on production on October 27, 2026: Metabase v0.64.3.5 from the `metabase/metabase:latest` image (1.08 GB, OpenJDK 25), once on a `default` sandbox (4 vCPU, 8 GB) and once on a `small` one (2 vCPU, 4 GB). Timings are single runs; yours will differ.

| Step | `default` | `small` |
| - | - | - |
| Pull the image and start, until `/api/health` says ok | 55 s | 37 s |
| Metabase's own "Initialization COMPLETE" | 14.5 s | 15.9 s |
| `docker restart` until healthy again | | 27 s |
| Container memory after setup | 1.6 to 1.8 GB | 1.3 to 1.4 GB |
| `SELECT COUNT(*) FROM ORDERS` on the Sample Database, warm | 5 to 20 ms in Metabase | 19 to 22 ms |
| Same query from a laptop through the hosted URL | 230 to 280 ms round trip | |
| `sbx stop --wait`, then `sbx resume` | 2.2 s stop, 1.4 s resume, `memoryRestored: true` | |

Both sizes worked. On `small`, Metabase left about 2.2 GB of the VM's memory available. Pick `default` if you will connect larger databases or run heavy dashboards; `small` costs half as much ($0.018/hour against $0.036/hour).

## 1. Start Metabase with a volume

Metabase's [Docker install docs](https://www.metabase.com/docs/latest/installation-and-operation/running-metabase-on-docker) give the image and the `MB_DB_FILE` setting. Docker is preinstalled in every sandbox. The pull takes a while, so raise `sbx exec`'s 30-second default timeout:

```bash
sbx new --name metabase --type default --no-env
id=sb_65jj424u1o    # the id that sbx new printed

sbx exec $id --timeout 600 "docker run -d --name metabase --restart unless-stopped \
  -p 3000:3000 -v mbdata:/metabase-data -e MB_DB_FILE=/metabase-data/metabase.db \
  metabase/metabase:latest"
sbx exec $id --timeout 300 "until curl -sf localhost:3000/api/health >/dev/null; do sleep 1; done; \
  curl -s localhost:3000/api/health; docker logs metabase 2>&1 | grep 'Initialization COMPLETE in'"
```

```text
{"status":"ok"}
… INFO core.core :: Metabase Initialization COMPLETE in 14.5 s (JVM uptime: 37.1 s)
```

The volume is the part people skip. Metabase stores its own settings, users, saved questions and dashboards in an application database. Without `MB_DB_FILE` on a volume, that file lives in the container's writable layer, and a new container (an upgrade, or `docker rm` by mistake) starts from the setup screen again. We checked by running a second container without the volume next to the first:

```text
port 3000: {"has-user-setup":true}     # recreated container, same mbdata volume
port 3001: {"has-user-setup":false}    # new container, no volume
```

For anything beyond a trial, Metabase recommends Postgres or MySQL as the [application database](https://www.metabase.com/docs/latest/installation-and-operation/configuring-application-database) instead of the file. We used the file here.

## 2. Give it a private URL, before you finish setup

```bash
sbx host $id 3000 --title Metabase
```

```text
https://broad-cobalt-brook-3000.box.sandboxer.com?_token=<token>
```

Do this with the default private access, not `--public`, and finish setup right away. Until someone creates the first admin, Metabase returns its setup token from `GET /api/session/properties` with no login at all (the script in step 3 reads it that way). On a public URL, whoever got there first could create the admin account. On the private URL, a request without the token never reaches Metabase:

```bash
curl -si https://broad-cobalt-brook-3000.box.sandboxer.com/ | head -1
curl -si -c jar "https://broad-cobalt-brook-3000.box.sandboxer.com?_token=<token>" | grep -iE '^HTTP|^location'
curl -s -b jar -o /dev/null -w '%{http_code} %{time_total}\n' https://broad-cobalt-brook-3000.box.sandboxer.com/api/health
```

```text
HTTP/2 401
HTTP/2 302
location: /
200 0.223655
```

Open the full `?_token=` URL in your browser. The proxy sets a 30-day cookie and redirects to `/`, and Metabase's setup screen loads. How the token and cookie work is in [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url).

## 3. Finish setup and query the Sample Database from a script

The browser wizard works. If an agent or a CI job sets Metabase up, use the API instead (see [Metabase's API docs](https://www.metabase.com/docs/latest/api)). This script runs on your laptop, goes through the private URL with the cookie jar from step 2, creates the admin, and runs a native query on the bundled Sample Database:

```bash
#!/bin/bash
set -euo pipefail
B=https://broad-cobalt-brook-3000.box.sandboxer.com
J=./jar                                   # cookie jar from the ?_token= visit
MB_PASS=$(openssl rand -base64 18)        # admin password
echo "$MB_PASS" > ./mb_pass && chmod 600 ./mb_pass
TOKEN=$(curl -s -b $J $B/api/session/properties | jq -r '."setup-token"')
SESSION=$(curl -s -b $J -H 'Content-Type: application/json' -X POST $B/api/setup -d "$(jq -n \
  --arg t "$TOKEN" --arg p "$MB_PASS" \
  '{token:$t, user:{first_name:"Ada", last_name:"Admin", email:"admin@example.com", password:$p, site_name:"Sandbox BI"},
    prefs:{site_name:"Sandbox BI", site_locale:"en", allow_tracking:false}}')" | jq -r .id)
H="X-Metabase-Session: $SESSION"
curl -s -b $J -H "$H" $B/api/database | jq -c '.data[] | {id, name, engine, is_sample}'
curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/dataset -d '{
  "database": 1, "type": "native",
  "native": {"query": "SELECT p.CATEGORY, COUNT(*) AS ORDERS, ROUND(SUM(o.TOTAL), 2) AS REVENUE FROM ORDERS o JOIN PRODUCTS p ON o.PRODUCT_ID = p.ID GROUP BY p.CATEGORY ORDER BY REVENUE DESC"}}' \
  | jq -c '{status, row_count, running_time, rows: .data.rows}'
```

```text
{"id":1,"name":"Sample Database","engine":"sqlite","is_sample":true}
{"status":"completed","row_count":4,"running_time":233,"rows":[["Widget",5061,423667.5],["Gadget",4939,406621.25],["Gizmo",4784,383061.95],["Doohickey",3976,297270.99]]}
```

In this version the Sample Database is a SQLite file (`engine: sqlite`), added automatically at setup. The first query took 233 ms. Later ones, after logging in again with `POST /api/session`, ran in 5 to 20 ms inside Metabase and took 230 to 280 ms end to end from our laptop. Most of that is the network path to the sandbox.

Keep the session id and password out of logs. The script above never prints them.

## 4. Connect a real database

The Sample Database is for learning the UI. To chart your own data, run the database next to Metabase on a Docker network and add it by container name. We started Postgres 17 with a small test table:

```bash
sbx exec $id --timeout 600 "docker network create bi && docker network connect bi metabase && \
  docker run -d --name pg --network bi --restart unless-stopped -e POSTGRES_PASSWORD=pw postgres:17 && \
  until docker exec pg pg_isready -q; do sleep 0.5; done && \
  docker exec pg psql -U postgres -c \"CREATE TABLE signups AS SELECT d::date AS day, (random()*100)::int AS n \
  FROM generate_series(now()-interval '29 days', now(), interval '1 day') d\""
```

Then add it through the same API (`$SESSION` from a fresh `POST /api/session` login):

```bash
DB=$(curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/database -d '{
  "engine": "postgres", "name": "App DB",
  "details": {"host": "pg", "port": 5432, "dbname": "postgres", "user": "postgres", "password": "pw"}}' | jq .id)
curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/dataset -d "{
  \"database\": $DB, \"type\": \"native\",
  \"native\": {\"query\": \"SELECT count(*) AS days, sum(n) AS signups FROM signups\"}}" \
  | jq -c '{status, rows: .data.rows}'
```

```text
{"status":"completed","rows":[[30,1355]]}
```

The host is `pg`, the container name, because both containers share the `bi` network. Postgres publishes no port, so nothing outside the VM can reach it. For a vector database or a database you seed from an agent, see [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) and [Test text-to-SQL queries against a real Postgres](/guides/test-text-to-sql-queries-against-a-real-postgres).

## 5. What survives a stop and resume

```bash
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker inspect -f 'restarts={{.RestartCount}}' metabase; curl -s localhost:3000/api/health"
```

```text
restarts=0
{"status":"ok"}
```

The stop took 2.2 seconds and the resume 1.4 seconds, with `memoryRestored: true`. The JVM was never restarted, so Metabase was usable at once: the old browser cookie still worked, and a login plus query returned the same 18,760 orders.

A resume boots from disk instead when no machine with the same CPU type is free, or when you resume on a bigger size. Then the `--restart unless-stopped` policy starts Metabase again, which took 27 seconds in our `docker restart` test on `small`. Users, questions and dashboards survive because they are in the `mbdata` volume on the sandbox disk. See [snapshots](/docs/snapshots#resume) for the rules.

A sandbox nobody uses pauses itself after `idleStopMinutes` (15 to 120 minutes by default, depending on your plan; our test sandbox had 30). Requests to its hosted URL count as activity, and a pause is a stop with memory, so Metabase comes back as it was. To keep a shared dashboard from pausing, see [idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use).

## 6. Clean up

```bash
sbx delete $id --yes
```

A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped.

## FAQ

### Is a small sandbox enough for Metabase?

For the Sample Database and light use, yes. On `small` (4 GB), the container used 1.3 to 1.4 GB after setup, queries ran in about 20 ms, and the VM still had 2.2 GB available. Use `default` for bigger data sources, many users, or heavy dashboards.

### Why do I land on the setup screen again after recreating the container?

The application database was inside the old container. Start Metabase with `-v mbdata:/metabase-data -e MB_DB_FILE=/metabase-data/metabase.db` (or point it at Postgres) so users, questions and dashboards live outside the container.

### Can I make the dashboard public?

`sbx host $id 3000 --public` opens the URL to anyone, and Metabase's own login still protects the data. Do it only after setup is finished, because before that anyone can claim the admin account. Switching back to private mints a new token, so old links stop working.

### Can Metabase in a sandbox query a database outside it?

Yes, if that database accepts connections from the sandbox. Sandboxes have outbound internet (the image pull above used it). We tested only databases inside the same VM. What a sandbox can reach is in [What can reach a sandbox and what it can reach](/guides/what-can-reach-a-sandbox-and-what-it-can-reach).

## Related

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox)
- [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service)
- [Hosting](/docs/hosting)
