# How to Run Umami Analytics in a Cloud Sandbox

> Run Umami's official Docker Compose stack in a small Sandboxer sandbox and keep its dashboard on a private URL. Visitors' browsers can't send the private URL's cookie, so add a small Caddy collector that exposes only /script.js and /api/send on a second, public URL. Countries come through correctly.

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

**Short answer:** Run Umami's official Docker Compose stack in a small Sandboxer sandbox and keep its dashboard on a private URL. Visitors' browsers can't send the private URL's cookie, so add a small Caddy collector that exposes only /script.js and /api/send on a second, public URL. Countries come through correctly.

[Umami](https://umami.is/docs) is open-source web analytics: a Next.js app and a Postgres database. The catch with any analytics tool behind a private URL is that the tracker runs in your visitors' browsers, not yours. This guide splits Umami into a private dashboard and a public collector, then checks that pageviews, countries and stored data survive a Docker restart, a stop and a boot from disk.

## What we ran

Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB, `eu-central`). We used Umami 3.4.1 from `ghcr.io/umami-software/umami:latest`, Postgres 15 and Caddy 2.11.7. Requests came from a laptop in the US. Times are single runs unless the table says otherwise.

| Step | Result |
| - | - |
| `sbx new --type small` | 3.2 s to ready |
| Images pulled | Umami 963 MB, Postgres 292 MB |
| Memory after start | Umami 143 MB, Postgres 34 MB |
| `POST /api/send` through the public collector, 20 calls | 208 ms median (195 to 226 ms) |
| Same call inside the sandbox on `localhost:3000` | 11 ms median |
| `sudo systemctl restart docker` until both URLs answer | 13.3 s |
| `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, `memoryRestored: true` |
| Resume with `--type default` (boots from disk) | Umami answered 12 s after we could run a command |

## 1. Why the tracker can't use the private URL

`sbx host` makes a port private by default: a request needs the `__sbx_auth` cookie, which the `?_token=` link sets ([Hosting](/docs/hosting)). That's right for the dashboard. It doesn't work for the tracker. The tracker script loads from your website's pages, in strangers' browsers that never opened your token link. The cookie is also `SameSite=Lax`, so a browser wouldn't send it on a cross-site script load or `fetch` anyway. With the stack running on a private port 3000:

```text
/script.js  no cookie: 401
/api/send   no cookie: 401
/script.js  with cookie: 200, 4773 bytes
```

Making port 3000 public would fix tracking, but it also puts Umami's login page and its whole API on the open internet. The fix is a second port that serves only the two paths the tracker needs.

## 2. The Compose file

This is Umami's own [docker-compose.yml](https://github.com/umami-software/umami/blob/master/docker-compose.yml) with four changes. The secrets come from a `.env` file. Umami is published on `127.0.0.1` only. `restart: unless-stopped` replaces `always`. A `collector` service is added.

```yaml
# umami/compose.yaml
services:
  umami:
    image: ghcr.io/umami-software/umami:latest
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DATABASE_URL: postgresql://umami:${DB_PASSWORD}@db:5432/umami
      APP_SECRET: ${APP_SECRET}
      DISABLE_TELEMETRY: "1"
    depends_on:
      db:
        condition: service_healthy
    init: true
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "curl -fs http://localhost:3000/api/heartbeat"]
      interval: 5s
      timeout: 5s
      retries: 5
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - umami-db-data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
  collector:
    image: caddy:2-alpine
    ports:
      - "127.0.0.1:8081:8081"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on:
      - umami
    restart: unless-stopped
volumes:
  umami-db-data:
```

The collector's [Caddyfile](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy) forwards the tracker's two paths and answers 404 for everything else:

```text
# umami/Caddyfile
:8081 {
	@collect path /script.js /api/send
	handle @collect {
		reverse_proxy umami:3000
	}
	handle {
		respond "Not found" 404
	}
}
```

Both ports are bound to `127.0.0.1` because the sandbox has a public IPv6 address. The hosted URLs reach them through the `--localhost` relay, so nothing else can.

## 3. Start it with fresh secrets

`APP_SECRET` signs Umami's login tokens ([environment variables](https://docs.umami.is/docs/environment-variables)). Generate it and the database password inside the sandbox, so they never show in your terminal:

```bash
sbx new --name umami --type small
id=sb_tx48hv6jrf    # the id that sbx new printed; its subdomain was icy-neat-delta

sbx scp -r ./umami $id:/home/user/
sbx exec $id --timeout 600 "cd umami && umask 077 \
  && printf 'DB_PASSWORD=%s\nAPP_SECRET=%s\n' \$(openssl rand -hex 16) \$(openssl rand -hex 32) > .env \
  && docker compose up -d --quiet-pull --wait \
  && docker compose ps --format '{{.Service}} {{.Status}} {{.Ports}}'"
```

```text
 Container umami-db-1 Healthy
 Container umami-umami-1 Healthy
 Container umami-collector-1 Healthy
collector Up Less than a second 80/tcp, 443/tcp, 2019/tcp, 443/udp, 127.0.0.1:8081->8081/tcp
db Up About a minute (healthy) 5432/tcp
umami Up About a minute (healthy) 127.0.0.1:3000->3000/tcp
```

We started Umami and Postgres first and added the collector a minute later, which is why the times differ. A single `up` starts all three. On first start Umami applies its database migrations, then logs `✓ Database is up to date.` and `Next.js 16.4.2 … Ready`.

## 4. Two URLs: private dashboard, public collector

```bash
sbx host $id 3000 --localhost --title umami
sbx host $id 8081 --localhost --public --title umami-collector
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_tx48hv6jrf","port":3000,"url":"https://icy-neat-delta-3000.box.sandboxer.com?_token=<token>","access":"private","isProtected":true,"localhost":true}
{"ok":true,"type":"port.hosted","sandboxId":"sb_tx48hv6jrf","port":8081,"url":"https://icy-neat-delta-8081.box.sandboxer.com","access":"public","isProtected":false,"localhost":true}
```

What each URL answered from the laptop, with no cookie:

| Path | Port 3000 (private) | Port 8081 (public collector) |
| - | - | - |
| `/` | 401 | 404 |
| `/script.js` | 401 | 200 |
| `/api/send` | 401 | 200 |
| `/login`, `/api/auth/login`, `/api/websites`, `/api/heartbeat` | 401, like every path without the cookie | 404 |

The dashboard needs no base-URL setting. Umami serves from `/`, so `/login`, `/websites` and a website's page all loaded through the private URL with the cookie. `BASE_PATH` is only for serving Umami under a subfolder, and it's a build-time option.

## 5. Change the default password, then use the API from a laptop

A new Umami has the user `admin` with password `umami`. Change it before anything else. The calls below go through the private URL: the proxy wants the cookie, and Umami wants its own bearer token from [`/api/auth/login`](https://docs.umami.is/docs/api/authentication). The proxy passes `Authorization` through untouched. `pw.json` holds `{"currentPassword":"umami","newPassword":"<new password>"}`.

```bash
B=https://icy-neat-delta-3000.box.sandboxer.com
curl -s -o /dev/null -c jar "$B/?_token=<token>"       # trade the token for the cookie once

curl -s -b jar -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"umami"}' $B/api/auth/login > auth.json
JWT=$(python3 -c "import json; print(json.load(open('auth.json'))['token'])")
curl -s -b jar -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d @pw.json $B/api/me/password
```

```text
{"id":"41e2b680-648e-4b09-bcd7-3e2b10c06264","username":"admin","role":"admin","createdAt":"2026-10-27T11:39:27.875Z"}
```

The old password then got `401 incorrect-username-password`. Our next call with the token from before the change also got `401`, so log in again with the new password. Then add a website:

```bash
curl -s -b jar -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"name":"Demo site","domain":"demo.example.com"}' $B/api/websites
```

```text
{"id":"25ed7865-18fd-4d73-9813-751a024a294c","name":"Demo site","domain":"demo.example.com",…}
```

## 6. Track a real page in a real browser

The tracker snippet points at the collector, not the dashboard. The script sends its events back to the host it was loaded from, so no extra setting is needed:

```html
<script defer src="https://icy-neat-delta-8081.box.sandboxer.com/script.js"
        data-website-id="25ed7865-18fd-4d73-9813-751a024a294c"></script>
```

To test it from a different origin, we served a page with that snippet on a third, public port and loaded it in headless Google Chrome, which is preinstalled in the sandbox. Then we read the counts through the dashboard URL with the [website stats API](https://docs.umami.is/docs/api/website-stats):

```bash
W=25ed7865-18fd-4d73-9813-751a024a294c              # the website id
END=$(date +%s)000; START=$((END - 3600000))        # the last hour, in ms
google-chrome --headless=new --no-sandbox --virtual-time-budget=8000 --dump-dom \
  --user-agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" \
  "https://icy-neat-delta-8090.box.sandboxer.com/?run=real"
curl -s -b jar -H "Authorization: Bearer $JWT" \
  "$B/api/websites/$W/metrics?type=hostname&startAt=$START&endAt=$END"
```

```text
[{"x":"demo.example.com","y":2},{"x":"icy-neat-delta-8090.box.sandboxer.com","y":1}]
```

Two things we learned here:

- **Bots are dropped quietly.** Headless Chrome with its own user agent (`HeadlessChrome`) loaded the page, and the pageview count didn't move. With a normal user agent it went from 3 to 4. `curl`'s default user agent gets `200 {"beep":"boop"}` and nothing is stored. Test with a browser user agent, or set `DISABLE_BOT_CHECK` while you test.
- **CORS is already open.** A preflight from `Origin: https://demo.example.com` got `204` with `access-control-allow-origin: *`, so any site can post to the collector.

## 7. Countries come through the proxy

Umami works out a visitor's country from their IP address. Behind two proxies, the sandbox's and Caddy's, that is easy to lose. It wasn't. Our laptop's events showed `US`, and the page Chrome loaded from inside the sandbox showed `FI`, the sandbox's own exit address:

```text
[{"x":"US","y":2},{"x":"FI","y":1}]
```

The Sandboxer proxy sends both `X-Forwarded-For` and `X-Real-Ip` with the client's address. We checked with a small header-echo server on a hosted port. Caddy rewrites `X-Forwarded-For` unless you list [trusted proxies](https://caddyserver.com/docs/caddyfile/options#trusted-proxies), but it passes `X-Real-Ip` on unchanged. Umami got the right country with and without a `trusted_proxies` setting. If you put a different proxy in front, check this again; `CLIENT_IP_HEADER` tells Umami which header to read.

## 8. Restarts, stop and resume

**Docker restart.** `sudo systemctl restart docker` brought all three containers back by themselves, and both URLs answered after 13.3 s. All 64 pageviews were still there, and the bearer token from before still worked, because `APP_SECRET` comes from `.env` and doesn't change.

**Stop and resume.** `sbx stop $id --wait` took 2.2 s and `sbx resume $id` 1.3 s, with `memoryRestored: true`. The containers kept running, and the dashboard and collector both answered right away.

**While it's stopped, visits are lost.** We stopped the sandbox and posted an event to the collector. On October 27, 2026 it got `404` with the page "No sandbox uses this address." Nothing queues those events, so they never reach Umami. A collector has to be running to count anything:

- New sandboxes stop after 1 hour by default. Create a collector with `sbx new --no-auto-stop`, or set a longer `--ttl` ([auto-stop](/docs/long-running#auto-stop)).
- Requests to hosted URLs count as activity, so steady traffic keeps idle auto-pause away. After a quiet spell, the next tracker request wakes the sandbox and is held while it resumes ([idle auto-pause](/docs/long-running#idle-auto-pause-and-wake-on-use)). We didn't test a wake from the tracker.

**Boot from disk.** A resume boots from disk when you resize or no machine with the same CPU type is free. We tested it with `sbx resume $id --type default`:

```text
type default memoryRestored False resumeReason resized
2.45 4.82           # /proc/uptime: a fresh boot
ready after 12 s more
collector Up 12 seconds
db Up 12 seconds (healthy)
umami Up 12 seconds (health: starting)
```

Docker started at boot and `restart: unless-stopped` brought the stack back. The dashboard, both URLs and every stored pageview came back with it.

## 9. Clean up

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

After the delete, `sbx info` returned `not_found` and both URLs returned 404. Export anything you want to keep first, for example with `docker compose exec -T db pg_dump -U umami umami > umami.sql` and `sbx scp`. We didn't run that step. A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped.

## FAQ

### Can I just make the Umami port public?

Yes, `sbx host $id 3000 --localhost --public` works, and Umami's own login still protects the dashboard. But the login page and the whole API are then open to anyone. The collector keeps those private and exposes only `/script.js` and `/api/send`.

### Why are my test pageviews missing?

Umami drops requests whose user agent looks like a bot, and it still answers `200`. `curl` and headless Chrome are both treated as bots. Send a normal browser user agent, or set `DISABLE_BOT_CHECK=1` while you test.

### Is `small` enough?

For a few sites, yes. Umami used 143 MB and Postgres 34 MB of the 4 GB, and an event took 11 ms inside the sandbox. We didn't load-test it.

### Will ad blockers block the collector?

Some block `script.js` and `/api/send` by name. Umami's `TRACKER_SCRIPT_NAME` and `COLLECT_API_ENDPOINT` rename them. Add the new names to the Caddyfile's `path` line too. We didn't test the renamed paths.

### Where does the data live?

In the `umami-db-data` Docker volume on the sandbox disk. It survives restarts, stop, resume and a boot from disk. It doesn't survive `sbx delete`.

## Related

- [Run Uptime Kuma in a sandbox](/guides/run-uptime-kuma-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)
- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [Hosting](/docs/hosting)
