Skip to main content
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 in CLOUDFLARE_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
Staging needs only the tunnel and DNS permissions.
terraform.tfvars
The provider is pinned to cloudflare/cloudflare ~> 5.29 and locked in the committed .terraform.lock.hcl.

What it manages

Per environment, in tunnel.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.general expects;
  • Node on your machine, because the lumenstat role runs the tracker check there;
  • vendor/umami in 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/lumenstat and joins the docker group.
For production, use 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

  • Installs unattended-upgrades and ufw, 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 bypass ufw, and the Lumenstat stack publishes none.
  • SSH: writes /etc/ssh/sshd_config.d/10-hardening.conf with PermitRootLogin no, PasswordAuthentication no, KbdInteractiveAuthentication no and PubkeyAuthentication yes. The file sorts before 50-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 with sshd -t, the whole configuration is checked again, and sshd is reloaded, not restarted, so open sessions stay. Still, keep a session open and test a fresh login after the first run.
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.
Does what deploy/deploy.sh does:
  1. checks that vendor/umami exists;
  2. adds the login user to the docker group and reconnects;
  3. creates /srv/lumenstat and /srv/lumenstat/src (mode 750, owned by the login user);
  4. on your machine, runs node scripts/build-tracker.mjs --check and scripts/stage-tracker-releases.sh;
  5. packs apps, deploy, vendor/umami, patches, packages (if present), README.md and LICENSE, without node_modules, .next, .git, .env* or build output, and unpacks them into /srv/lumenstat/src;
  6. runs deploy/bootstrap.sh, which creates the env file on the first run, builds, starts the stack and replaces Umami’s default admin password;
  7. when lumenstat_cloudflare_tunnel_token is set, writes it to /srv/lumenstat/.env (mode 600, never logged) and recreates cloudflared.
The playbook touches only /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:
The server keeps its own secrets in /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 first apply. 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

The plan must show the imports and at most small in-place updates, for example HSTS if it was not set before. If it wants to replace or create the tunnel or a record, fix that ID first.
4

Apply and clean up

The imported tunnel keeps its token, so the server’s /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. and otlp. 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.
Last modified on November 14, 2026