Traefik
Traefik is the reverse proxy every other service sits behind — it terminates TLS and routes requests to the right container. Set it up first.
Last run on a host 1 Nov 2026
Pinned at traefik:v3.7.17 (released 30 Oct 2026) · the newest upstream release
In the upstream images: 0 CRITICAL · 7 HIGH with a published fix — per image
Installation
Section titled “Installation”This path uses Cloudflare DNS-01 for a wildcard certificate — it doesn’t require port 80 to be publicly reachable. Needs: a domain with DNS at Cloudflare, and a Cloudflare API token (My Profile → API Tokens → Custom Token, with Zone:DNS:Edit + Zone:Zone:Read).
Don’t use Cloudflare, or don’t want a wildcard? See “Certificate strategy” under Going further — per-domain certificates work with or without Cloudflare.
-
From
core/traefik/:Terminal window cp .env.example .env -
Set these in
.env:ACME_EMAIL[email protected]TRAEFIK_DASHBOARD_HOST=traefik.example.comCF_DNS_API_TOKEN=<your Cloudflare token>ACME_WILDCARD_DOMAIN=example.comUnless you run IPv4 only, also replace the example IPv6 prefix in
PUBLIC_NETWORK_SUBNET_V6with one of your own —validate.shrefuses the example on purpose (dual-stack, below). -
List the edge networks of the stacks you will install (see Edge networks below). It can stay empty until the first stack:
EDGE_NETWORKS="whoami-edge nextcloud-edge" -
Check, render, check again and start:
Terminal window bash ops/scripts/validate.shbash ops/scripts/render.shbash ops/scripts/validate.shdocker compose up -d -
Confirm both containers are healthy:
Terminal window docker compose ps
Verify
Section titled “Verify”curl -I https://<your-dashboard-domain>The dashboard is Tailscale-only by default — connect over your tailnet to reach it.
Check certificate issuance:
docker exec traefik-core cat /etc/traefik/acme/acme.json | jq '.[] | .Certificates[]?.domain // "no certs yet"'Going further
Section titled “Going further”Edge networks: one per stack
Section titled “Edge networks: one per stack”Each application stack ships on a Docker network of its own,
TRAEFIK_NETWORK=${COMPOSE_PROJECT_NAME}-edge — nextcloud-edge, whoami-edge.
Traefik creates and joins exactly the networks listed in EDGE_NETWORKS in
core/traefik/.env: render.sh writes them into config/edge-networks.yml, which
COMPOSE_FILE applies, and each stack declares its network as external. Nothing
creates a network automatically when a stack appears.
A stack whose network is not listed is unreachable, and Traefik’s log says
nothing. validate.sh compares the network label of every running routed
container with EDGE_NETWORKS and with the networks Traefik has joined, and
reports the one missing.
Adding a stack is: list its network, bash ops/scripts/render.sh,
docker compose up -d in core/traefik, then start the stack. That up -d
recreates Traefik, which interrupts every stack it routes for about seven
seconds — plan it like a short maintenance window.
On its own edge network a stack’s web container reaches Traefik and its own
internal network, nothing else. proxy-public stays as the shared choice
(TRAEFIK_NETWORK=proxy-public) for stacks that must reach each other by service
name, at the cost that every member reaches every other directly. It also carries
Traefik’s published ports in both shapes. Moving an existing shared installation
and the way back:
core/traefik/docs/edge-networks.md.
Networking: dual-stack by default, two bind variables
Section titled “Networking: dual-stack by default, two bind variables”Host ingress is two variables in core/traefik/.env, one per address family:
TRAEFIK_BIND_V4 and TRAEFIK_BIND_V6. render.sh writes them into
config/published-ports.yml, which COMPOSE_FILE applies; docker-compose.yml
publishes nothing itself. An empty variable publishes nothing for its family, and
neither has a default — render.sh refuses TRAEFIK_BIND_V4 unset, so a missing
line never binds every address. An IPv6 binding needs network-dual-stack.yml,
which makes the proxy network carry IPv6; render.sh refuses one without it.
The failure dual-stack prevents, in this architecture, is Tailscale: it always hands a client both addresses, and if the client connects over IPv6 to a proxy network without IPv6, Docker hands the connection to Traefik from the bridge gateway — the access policy then sees a private address, not the client, and answers 403. Traffic arriving through a Cloudflare-proxied (orange-cloud) record is unaffected, because the real address is recovered from a trusted forwarded header. With the dual-stack default, a tailnet client is seen with its own address over IPv4 and IPv6, and so is a client off the host over public IPv4 and IPv6.
-
Generate your own IPv6 ULA prefix — the example in
.env.exampleis refused on purpose:Terminal window RANDOM_HEX=$(openssl rand -hex 5)printf 'fd%s:%s:%s::/64\n' "${RANDOM_HEX:0:2}" "${RANDOM_HEX:2:4}" "${RANDOM_HEX:6:4}" -
Check for subnet conflicts:
Terminal window docker network lsdocker network inspect <network-name> --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}' -
Back up and edit
/etc/docker/daemon.json, merging in:{"ipv6": true,"ip6tables": true,"userland-proxy": false}Then restart the daemon — this briefly affects every container on the host:
Terminal window sudo systemctl restart docker -
In
core/traefik/.env, set the prefix; the rest is the example’s default:PUBLIC_NETWORK_SUBNET_V4=172.30.0.0/16PUBLIC_NETWORK_SUBNET_V6=<your generated prefix>/64TRAEFIK_BIND_V4=0.0.0.0TRAEFIK_BIND_V6=[::]COMPOSE_FILE=docker-compose.yml:network-dual-stack.yml:config/published-ports.yml:config/edge-networks.yml
Leave TRAEFIK_BIND_V6 empty and drop the overlay from the list:
TRAEFIK_BIND_V6=COMPOSE_FILE=docker-compose.yml:config/published-ports.yml:config/edge-networks.ymlA Tailscale client is then served over IPv4 only.
Leave TRAEFIK_BIND_V4 empty and keep the overlay: no public IPv4 ingress. What
the host needs for IPv6-only outbound connections — registries and sources without
AAAA records — is a prerequisite list, not a mode; see the ingress audit, §3.4.
This shape is experimental.
Exposure profile and host firewall
Section titled “Exposure profile and host firewall”There is no exposure variable. validate.sh derives a profile from the two
bindings and TRAEFIK_FORWARDED_PROXY — direct (published to everyone),
proxied (behind Cloudflare) or private (only loopback, LAN or VPN addresses) —
and prints which protection layers are present and which are absent: host
firewall, CrowdSec, forward-auth, origin protection.
What the internet actually reaches only a measurement from outside shows:
scripts/exposure-test.sh <address> <profile>, run from a machine that is not on
the VPN, ends with a marker line you append to config/exposure-verified.
validate.sh --strict requires that marker for a public binding.
render.sh also writes a host firewall for the profile,
config/host/ingress.nft — an nftables table of its own that closes the host’s
input path except for the VPN and keeps Docker’s forward path open. It is
optional, and nothing in the blueprint loads it: the operator installs it.
For the direct profile it closes the input path as described on a running host,
persists through reboots, and leaves the VPN and the published ports working.
How to load it with a way back:
core/traefik/docs/host-firewall.md.
Certificate strategy
Section titled “Certificate strategy”The path above is one of four. The others work too — the only hard constraint is that wildcard certificates require DNS-01, since Let’s Encrypt issues wildcards over no other challenge, whatever the server can reach.
One DNS-01 certificate covers *.example.com — what the installation above sets up. Needs a Cloudflare account and token; does not need port 80 reachable from the internet. Best when several services share one domain, and keeps covered service names out of public Certificate Transparency logs — including the Traefik dashboard, which uses the same certificate. Leave TRAEFIK_DASHBOARD_CERT_RESOLVER empty for that. A wildcard matches exactly one label: traefik.example.com is covered, traefik.admin.example.com is not.
Each application requests its own DNS-01 certificate through Cloudflare — still no public port 80 needed. Leave ACME_WILDCARD_DOMAIN unset; in each app’s .env set APP_TRAEFIK_CERT_RESOLVER=cloudflare-dns, and uncomment the tls.certresolver label in its docker-compose.yml.
Each application requests its own certificate via the standard HTTP-01 challenge — no Cloudflare account or token at all. Requires port 80 to be publicly reachable on this server. Leave ACME_WILDCARD_DOMAIN unset; in each app’s .env set APP_TRAEFIK_CERT_RESOLVER=httpResolver, and uncomment the tls.certresolver label. On both per-domain paths the dashboard needs TRAEFIK_DASHBOARD_CERT_RESOLVER set as well.
Each application requests its own certificate over port 443 itself — Let’s Encrypt opens a TLS connection, asks for the ALPN protocol acme-tls/1, and Traefik answers on the port it already listens on. No port 80 forward, no Cloudflare account, no DNS credentials: a firewall that forwards a single TCP port is enough. Leave ACME_WILDCARD_DOMAIN unset; in each app’s .env set APP_TRAEFIK_CERT_RESOLVER=tlsResolver, and uncomment the tls.certresolver label.
Two things rule it out: a wildcard (DNS-01 only), and anything that terminates TLS in front of this server. A CDN or load balancer answers the handshake itself and never forwards the ALPN protocol, so issuance fails with cannot negotiate ALPN protocol "acme-tls/1" — including Cloudflare’s proxy on orange cloud. Set the record to DNS-only, or use a DNS-01 resolver there.
These combine, too: keep a wildcard for your main domain and let individual apps opt into their own certificate (either resolver) via the same per-app label.
The setup check refuses the one combination that fails silently — no wildcard covering a hostname and no resolver for it — because Traefik answers such a request with its own self-signed certificate and writes nothing to the log. When upgrading a wildcard installation that already issued a separate dashboard certificate, emptying the variable stops further requests for it; the certificate already stored keeps renewing, and a hostname already in the transparency logs stays there.
Cloudflare’s proxy status (orange-cloud vs. DNS-only) is a separate, per-record choice, and it is irrelevant to every path above except one: TLS-ALPN-01 needs the TLS handshake to reach this server, so an orange-cloud record breaks it and a DNS-only record does not.
Troubleshooting
Section titled “Troubleshooting”docker compose logs traefik 2>&1 | grep -iE "acme|cert|challenge" | tail -20Most first-setup certificate failures are a Cloudflare token missing zone permissions, or a mismatch between ACME_WILDCARD_DOMAIN and the per-app tls.certresolver label state.
Backup
Section titled “Backup”| State | volumes/letsencrypt/ — acme.json, holding the ACME account key and every issued certificate |
| Also needed | .env — it holds CF_DNS_API_TOKEN and, with the bouncer on, CROWDSEC_BOUNCER_KEY, and the render needs it |
| Reproducible | config/ — rendered by ops/scripts/render.sh from the templates and .env · volumes/logs/ · volumes/plugins-storage/, downloaded again on start |
| Database | None. |
| Quiescing | Not needed. acme.json is rewritten atomically on issuance and renewal. |
acme.json contains private keys. It is small enough to overlook and grants
the ability to impersonate every host it covers, so the archive that holds it
deserves the same protection as a secret store. Its file mode is 600 and
Traefik refuses to start if that is widened: a restore that flattens permissions
produces a proxy that will not come up, which during an incident reads as a much
larger failure than it is.
Certificates can be reissued, so a lost acme.json is recoverable — but
reissuing during an outage means DNS or HTTP validation has to work while the
proxy is down.
Restore order: first. Nothing else in the deployment is reachable until the
proxy is up. Put .env back, render, then restore volumes/letsencrypt/ with
its permissions intact.
Updates
Section titled “Updates”# Read release notes first: https://github.com/traefik/traefik/releases# Bump TRAEFIK_IMAGE in .env, then:bash ops/scripts/render.shdocker compose pulldocker compose up -d # recreates Traefik: every routed stack pauses ~7 sdocker compose logs traefik 2>&1 | grep -i error # should be emptyNever jump major versions without reading the migration guide — the configuration schema changes.
Repository files
Section titled “Repository files”core/traefik/README.md— full setup, the middleware model, the CrowdSec and Authentik switchescore/traefik/docs/ipv6-dual-stack.md— dual-stack networking in depthcore/traefik/docs/edge-networks.md— edge networks, the shared network, migration and the way backcore/traefik/docs/host-firewall.md— exposure profile, host firewall, exposure testcore/traefik/.env.example— all configurable variablesdocs/standards/traefik-security.md— the four router positions (access, rate limit, headers, auth), the entrypoint pair, TLS profilesTROUBLESHOOTING.md— symptoms this blueprint has hit, with their fixesdocs/standards/troubleshooting.md— the layer-by-layer method and command reference
© 2026 SecDockBlueCode Apache-2.0Site content CC BY-NC 4.0