How to Run Umami Analytics in a Cloud Sandbox
Updated October 27, 2026 .md
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 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). 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:
/script.js no cookie: 401
/api/send no cookie: 401
/script.js with cookie: 200, 4773 bytesMaking 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 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.
# 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 forwards the tracker's two paths and answers 404 for everything else:
# 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). Generate it and the database password inside the sandbox, so they never show in your terminal:
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}}'" 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/tcpWe 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#
sbx host $id 3000 --localhost --title umami
sbx host $id 8081 --localhost --public --title umami-collector{"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. The proxy passes Authorization through untouched. pw.json holds {"currentPassword":"umami","newPassword":"<new password>"}.
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{"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:
curl -s -b jar -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"name":"Demo site","domain":"demo.example.com"}' $B/api/websites{"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:
<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:
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"[{"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 gets200 {"beep":"boop"}and nothing is stored. Test with a browser user agent, or setDISABLE_BOT_CHECKwhile you test. - CORS is already open. A preflight from
Origin: https://demo.example.comgot204withaccess-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:
[{"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, 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). - 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). 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:
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#
sbx delete $id --yesAfter 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#
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.