ADR-0008: Traefik v3 label-routing edge with TLS terminated at Cloudflare
Status: Superseded by ADR-0014 for the hosted shared stack (still the shape for legacy per-client on-prem installs) Date: 2026-07-24
Context
The per-client-stack model (ADR-0007) needs one shared entrypoint on the VPS
that routes <slug>.<domain> to the right client's frontend container, and
new clients must become routable without touching the edge — onboarding is
supposed to be "run one script" (the 24-hour promise in the
VPS guide).
TLS could be terminated at the edge (Traefik + Let's Encrypt with a DNS-01 wildcard challenge) or offloaded to Cloudflare, which already fronts the domain for DNS. Automating wildcard ACME requires storing a Cloudflare API token on the VPS and per-provider Traefik config; offloading to Cloudflare requires nothing.
Decision
- Traefik v3 (
traefik:v3.3) as the single edge container (infra/traefik/docker-compose.yml), using the Docker provider with label-based routing:exposedbydefault=false, and each client stack self-registers via labels indocker-compose.client.template.yml(Host(\${CLIENT_SLUG}.${BASE_DOMAIN}`)`). Starting a client stack is all it takes to become routable. - HTTP-only origin: Traefik listens on :80 only — no
:443, no certificates, no ACME. Browsers get HTTPS because the Cloudflare-proxied wildcard DNS record terminates TLS at Cloudflare's edge (Flexible-style: HTTPS browser→Cloudflare, plain HTTP Cloudflare→origin). DOCKER_API_VERSION=1.44pin in the Traefik environment: negotiates down to the Docker Engine API version available on the host so the Traefik image and host Docker can upgrade independently.- Compensating control (documented in the VPS guide): the UFW firewall should restrict :80/:443 to Cloudflare's IP ranges, so the plaintext origin port is not reachable from the open internet.
Consequences
Positive
- Zero-touch client routing: onboarding never edits edge config; the edge container "stays running forever" while stacks come and go.
- No certificate machinery on the VPS at all — no ACME renewals to break, no Cloudflare API token stored on the origin server.
- Cloudflare's proxy adds DDoS absorption and hides the origin IP for free.
Negative
- The Cloudflare→origin hop is unencrypted HTTP. Anyone who can observe traffic between Cloudflare and the VPS sees payroll data in the clear. The firewall restriction to Cloudflare IP ranges limits who can connect, not who can observe — this is the weakest link in the transport story.
- Hard Cloudflare dependency: DNS, TLS, and the security model all assume Cloudflare proxying. Grey-clouding the record serves the app over plain HTTP; migrating providers means building origin TLS first.
- No origin certificate yet — the known fix (Cloudflare origin CA cert + "Full (strict)" mode, or Traefik ACME) is deliberately deferred, so the gap is accepted, not unnoticed.
- The pinned
DOCKER_API_VERSIONmust be revisited when the host's Docker Engine is upgraded.
References
../../infra/traefik/docker-compose.yml../../docker-compose.client.template.yml— Traefik labels on the frontend service../deployment/vps-guide.md— DNS, firewall, onboarding runbook- 0007-per-client-isolated-stacks.md