infra/ describes the one-server setup as code. OpenTofu manages the Cloudflare side: the tunnel and its routes, the DNS records, the zone settings and the sign-up rate limit. Ansible prepares an Ubuntu 24.04 host and deploys the stack the same way deploy/deploy.sh does. deploy.sh keeps working on its own; this is the repeatable version of it.
Layout
Environments
Both environments live in the same Cloudflare zone. Each has its own tunnel, its own hostnames and its own server.
Staging names stay one label below the zone because the Free plan’s Universal SSL certificate covers only first-level subdomains.
app.staging.example.com would need Advanced Certificate Manager; with it, change the names in environments/staging/main.tf.
Each host runs one stack in /srv/lumenstat, so staging needs its own server.
deploy/compose.yaml sets the dashboard’s PUBLIC_UMAMI_URL and PUBLIC_COLLECTOR_URL to the production hostnames. On staging, setup snippets show those until this is made configurable.Order of operations
1
OpenTofu
Creates the tunnel, its routes and the DNS records, and in production the zone settings.
2
Token
tofu output -raw tunnel_token goes into the Ansible secret lumenstat_cloudflare_tunnel_token, or by hand into CLOUDFLARE_TUNNEL_TOKEN= in /srv/lumenstat/.env.3
Ansible
Prepares the host, copies the sources to
/srv/lumenstat/src, runs deploy/bootstrap.sh, writes the token and starts cloudflared.OpenTofu
You need OpenTofu 1.8 or newer and a Cloudflare API token inCLOUDFLARE_API_TOKEN, limited to your account and zone, with at least:
- Account › Cloudflare Tunnel: Edit
- Zone › DNS: Edit
- Zone › Zone Settings: Edit
- Zone › Zone WAF: Edit, for the rate limiting rule
terraform.tfvars
cloudflare/cloudflare ~> 5.29 and locked in the committed .terraform.lock.hcl.
What it manages
Per environment, intunnel.tf:
The
t. route allows only ^/(script\.js|api/send|e|v/[0-9]+\.[0-9]+\.[0-9]+/l\.js)$, which keeps Umami’s own interface and API off the internet. The overview explains why.
Production only, when manage_zone = true, in zone.tf:
The Free plan allows only the path in a rate limiting rule’s expression. Matching on host and method needs Pro or Business. So the rule applies to
/signup on every hostname in the zone, which in practice means both dashboards. On a paid plan you can narrow it, as the comment in zone.tf shows.Module inputs and outputs
Outputs:
tunnel_id, tunnel_token (sensitive), hostnames and, in production, dnssec_ds.
State
State is local by default and contains the tunnel token, so treat it as a secret. Keep it off shared drives and out of git;.gitignore covers it. To share it, create a private Cloudflare R2 bucket, export the R2 token’s key pair as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, uncomment the backend "s3" block in versions.tf and run tofu init -migrate-state. OpenTofu’s state encryption can encrypt it as well.
Ansible
You need:- ansible-core 2.19 or newer on your machine, which the current
community.generalexpects; - Node on your machine, because the
lumenstatrole runs the tracker check there; vendor/umamiin place (scripts/sync-umami.sh), with its dependencies installed for the tracker check;- a server with Ubuntu 24.04 where you log in as a sudo user with your SSH key, not root. That user becomes the deploy user: it owns
/srv/lumenstatand joins thedockergroup.
inventories/production/hosts.yml.
inventories/staging/hosts.yml
site.yml runs three roles on the lumenstat group. base and docker run with become. lumenstat runs as the login user and asks for root only where it must.
Roles
base: updates, firewall, SSH
base: updates, firewall, SSH
- Installs
unattended-upgradesandufw, and turns on daily unattended upgrades. - Firewall: allows SSH, denies other incoming connections by default, and enables
ufw. Rules are added to what is there; nothing is reset. Docker-published ports bypassufw, and the Lumenstat stack publishes none. - SSH: writes
/etc/ssh/sshd_config.d/10-hardening.confwithPermitRootLogin no,PasswordAuthentication no,KbdInteractiveAuthentication noandPubkeyAuthentication yes. The file sorts before50-cloud-init.conf, so its values win. - Safety checks: it stops with an error if Ansible is logged in as root, or if the login user has no valid key in
~/.ssh/authorized_keys. The drop-in is validated withsshd -t, the whole configuration is checked again, andsshdis reloaded, not restarted, so open sessions stay. Still, keep a session open and test a fresh login after the first run.
docker: Docker Engine and Compose
docker: Docker Engine and Compose
Installs
docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin and docker-compose-plugin from Docker’s apt repository, and starts Docker at boot. If Docker is already installed, for example for another project on the host, it is left as it is. The role stops with an error if that installation has no working docker compose.lumenstat: deploy the stack
lumenstat: deploy the stack
Does what
deploy/deploy.sh does:- checks that
vendor/umamiexists; - adds the login user to the
dockergroup and reconnects; - creates
/srv/lumenstatand/srv/lumenstat/src(mode 750, owned by the login user); - on your machine, runs
node scripts/build-tracker.mjs --checkandscripts/stage-tracker-releases.sh; - packs
apps,deploy,vendor/umami,patches,packages(if present),README.mdandLICENSE, withoutnode_modules,.next,.git,.env*or build output, and unpacks them into/srv/lumenstat/src; - runs
deploy/bootstrap.sh, which creates the env file on the first run, builds, starts the stack and replaces Umami’s default admin password; - when
lumenstat_cloudflare_tunnel_tokenis set, writes it to/srv/lumenstat/.env(mode 600, never logged) and recreatescloudflared.
/srv/lumenstat, the lumenstat Compose project and the host defaults above. Other projects’ containers, networks and firewall rules are left alone. No port is published, so the stack is reachable only through the tunnel.
Secrets
Nothing secret goes into the repository: no API token, tunnel token,.tfvars file, real inventory or env file. infra/.gitignore covers state, *.tfvars, imports.tf, hosts.yml, inventory group_vars and host_vars, and anything decrypted from SOPS.
Keep per-environment secrets in files encrypted with SOPS and age, stored outside the public repository, for example in a private operations repository:
/srv/lumenstat/.env, created by bootstrap.sh on the first run. See Configuration.
Adopt an existing zone and tunnel
If the zone, tunnel and records were created by hand, import them before the firstapply. Otherwise OpenTofu tries to create a second tunnel, duplicate DNS records and a second rate limiting ruleset, and fails half-way.
1
Find the IDs
With an API token in
CLOUDFLARE_API_TOKEN, and your account and zone IDs in ACCOUNT_ID and ZONE_ID:2
Write the import blocks
In
environments/production, copy the example and replace each <…> placeholder:The account and zone IDs come from
var.account_id and var.zone_id, so you fill in only the tunnel, record and ruleset IDs. Drop the block for anything that does not exist yet; apply creates it.3
Plan
4
Apply and clean up
/srv/lumenstat/.env stays valid. Staging is new and has nothing to import.
Limits
- The code covers Cloudflare and one host per environment. It sets up no backups, monitoring or alerts.
- The routes send
t.andotlp.through Cloudflare, which sees visitor IPs in transit. See the privacy note.
Planned: off-site backups in the EU, alerts, and serving
t. and otlp. directly from EU servers are part of Milestone 3. See the roadmap.