cloudflared. No port is published. The only way in is a Cloudflare Tunnel that cloudflared opens outbound, and it routes only the dashboard, the tracker files, the collect endpoint and the OTLP endpoints.
The files are deploy/compose.yaml, deploy/deploy.sh and deploy/bootstrap.sh. Infrastructure as code describes the same setup with OpenTofu and Ansible, and Configuration lists every variable.
Services
Every service restarts unless stopped, and every service has memory and CPU limits, so the stack can share a host with other projects. The ports above are on the stack’s private network only.
Network: no published ports
cloudflared connects out to Cloudflare. Cloudflare matches each request against the tunnel’s routes, in order, and sends it down the tunnel to a Compose service by name:
Why
t. has a path rule. Umami serves its admin interface, its login and its REST API on the same port as the tracker. The expression lets through four paths and nothing else: the versioned releases under /v/, the legacy /script.js, the collect endpoint /e and the legacy /api/send. It is anchored at both ends, so /e/anything or /api/send/x does not match. Every other path on t. falls through to the 404 rule at Cloudflare’s edge. Umami’s login page and API never receive a request from the internet. Admin work goes over the private network: the dashboard calls http://umami:3000 with the admin account to enroll projects.
Other consequences of the routes:
- OTLP is HTTP only from outside. The collector also listens for gRPC on 4317, but no route reaches it.
- The writer has no route. The metrics API answers on the private network, where the dashboard reads it.
- The visitor IP comes from Cloudflare. Umami reads it from
cf-connecting-ip(CLIENT_IP_HEADER). The origin is reachable only through the tunnel, so a client cannot set that header itself.
cloudflared runs with a token and no local config.
First deploy
You need:- On your machine: a checkout of the repository, Node 24,
sshaccess to the server, andtar. - On the server: Linux with Docker Engine and the Compose plugin,
openssl, and a login user that cansudoand rundocker. The Ansible playbook can prepare an Ubuntu 24.04 host for you. - At Cloudflare: a zone for your domain and a remotely managed tunnel with the routes above.
1
Build vendor/umami
vendor/umami is generated: the pinned Umami tag plus the patches. The tracker check in the next steps runs Rollup inside it, so install its dependencies too.2
Point the dashboard at your hostnames
In Tracker releases send to
deploy/compose.yaml, set the dashboard’s public addresses, which appear in every setup snippet:deploy/compose.yaml
https://t.lumenstat.com by default. When PUBLIC_UMAMI_URL is anything else, the Setup page adds data-host-url to the snippet so events reach your server.3
Run the deploy script
<ssh host> is an alias from your ~/.ssh/config or user@host. On your machine the script:- stops if
vendor/umamiis missing; - runs
node scripts/build-tracker.mjs --check, so the released tracker must match the patched source byte for byte; - runs
scripts/stage-tracker-releases.sh, which copies every release tovendor/umami/public/v/<version>/l.js; - creates
/srv/lumenstatand/srv/lumenstat/srcon the server (mode 750, owned by your login user); - streams
apps,deploy,vendor/umami,patches,README.mdandLICENSEoversshinto/srv/lumenstat/src, withoutnode_modules,.next,.git,.envfiles or build output; - runs
deploy/bootstrap.shon the server.
bootstrap.sh is safe to run again. It:- creates
/srv/lumenstat/.envwith random secrets, on the first run only, with mode 600; - builds the images and starts every service except
cloudflared; - waits up to seven and a half minutes for Umami’s heartbeat, then replaces Umami’s default
admin/umamipassword withUMAMI_ADMIN_PASSWORDfrom the env file, over the private network, before anything is reachable; - starts
cloudflaredifCLOUDFLARE_TUNNEL_TOKENis set, and otherwise says the stack is running but not reachable.
4
Add the tunnel token
Put the token in the env file on the server, never in git:Then run
/srv/lumenstat/.env
deploy/deploy.sh <ssh host> again, or start the tunnel on the server:5
Confirm it works
https://app.example.com as admin with UMAMI_ADMIN_PASSWORD from /srv/lumenstat/.env. Umami admins are SCF operators, so you see the operator screens.On the server
Updating
Rundeploy/deploy.sh <ssh host> again. It repeats the tracker check, copies the sources over /srv/lumenstat/src, rebuilds the images and recreates the containers that changed. The database volume and /srv/lumenstat/.env are kept, and the password step reports that the default password was already replaced. Umami applies its Prisma migrations and the writer applies apps/writer/db/migrations when they start.
The copy adds and overwrites files but never deletes them. A file removed from the repository stays in
/srv/lumenstat/src until you remove it there.- Data-center ranges. After
node scripts/update-datacenter-ranges.mjsand a deploy, rundc restart umami. The file is mounted read-only and Umami keeps it in memory, so a changed file alone does not recreate the container. - Umami itself. Bump
UMAMI_TAGinscripts/sync-umami.sh, run it, fix any conflicts invendor/umami, then write the patches back withscripts/export-umami-patches.sh. Every upgrade is a reviewed diff.
Privacy: serve t. and otlp. DNS-only
Planned: serving
t. and otlp. directly from EU servers is part of Milestone 3 for the hosted service. The repository does not include a configuration for it yet.t. and otlp. at your server and terminate TLS there, for example with Caddy and Let’s Encrypt. That reverse proxy publishes a port, so the “no published ports” property then holds for the dashboard only. Change these at the same time:
- The path allow-list. Apply the same two expressions in your proxy, or Umami’s login and API become public.
CLIENT_IP_HEADER.cf-connecting-ipis no longer set by Cloudflare, and any client could send it. Set it to the header your proxy writes, and make the proxy overwrite any value the client sent. Otherwise a client chooses the IP that the data-center and per-network checks see.- Keep
SKIP_LOCATION_HEADERS=1.deploy/compose.yamlalready sets it, so Umami ignorescf-ipcountryand looks the country up in its own GeoLite database. Without it, and without Cloudflare in front, a client could send that header and pick its own country. - DNS in OpenTofu. The module creates proxied CNAMEs for all three hostnames. Change the records for
t.andotlp..
Run locally for development
The rootdocker-compose.yml runs the core services with ports bound to 127.0.0.1, and adds a test site and an instrumented test API under the examples profile.
Then:
- Open Umami at
http://localhost:3000. The first-run login isadmin/umami; change it. - Visit
http://localhost:8088to generate page views. - Open the API dashboard at
http://localhost:8090/dashboardwith the read key fromcreate-api-tenant.sh, or the operator key in.env. - Run
scripts/smoke-web.sh <website id>to see which requests the collect endpoint counts and which it rejects.
apps/dashboard:
- Only
SCF_ENFORCE_ORIGINof the quality switches is on. Layers 2 to 4 are off. - Umami is built without
COLLECT_API_ENDPOINT, so the collect endpoint is/api/sendonly. The test site uses Umami’s own/script.js, which posts there. Tracker releases andscripts/fake-event.mjspost to/e. To use them locally, build withdocker compose build --build-arg COLLECT_API_ENDPOINT=/e umami. - No
CLIENT_IP_HEADERis set, so Umami trustsx-forwarded-forfrom any client.fake-event.mjs --iprelies on that to test layers 3 and 4.
Without building the app images
Without building the app images
If Docker Hub is slow or unreachable, run only the data services in Docker and the apps on the host, with Node 24 and pnpm 12:
SKIP_BUILD_GEO=1 skips the GeoLite download. Local IPs have no country anyway.Tests
Tests
Limits
- One server, one stack in
/srv/lumenstat. There is no failover, and a second environment needs its own host. - No backups, monitoring or alerts are set up by these files.
Planned: off-site backups in the EU with a restore drill, alerts and a public status page, and ingest in two locations with failover are part of Milestone 3. See the roadmap.