Skip to main content

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 in docker-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.44 pin 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_VERSION must be revisited when the host's Docker Engine is upgraded.

References