Skip to content

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

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.

  1. From core/traefik/:

    Terminal window
    cp .env.example .env
  2. Set these in .env:

    TRAEFIK_DASHBOARD_HOST=traefik.example.com
    CF_DNS_API_TOKEN=<your Cloudflare token>
    ACME_WILDCARD_DOMAIN=example.com

    Unless you run IPv4 only, also replace the example IPv6 prefix in PUBLIC_NETWORK_SUBNET_V6 with one of your own — validate.sh refuses the example on purpose (dual-stack, below).

  3. 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"
  4. Check, render, check again and start:

    Terminal window
    bash ops/scripts/validate.sh
    bash ops/scripts/render.sh
    bash ops/scripts/validate.sh
    docker compose up -d
  5. Confirm both containers are healthy:

    Terminal window
    docker compose ps
Terminal window
curl -I https://<your-dashboard-domain>

The dashboard is Tailscale-only by default — connect over your tailnet to reach it.

Check certificate issuance:

Terminal window
docker exec traefik-core cat /etc/traefik/acme/acme.json | jq '.[] | .Certificates[]?.domain // "no certs yet"'

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.

  1. Generate your own IPv6 ULA prefix — the example in .env.example is 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}"
  2. Check for subnet conflicts:

    Terminal window
    docker network ls
    docker network inspect <network-name> --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}'
  3. 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
  4. In core/traefik/.env, set the prefix; the rest is the example’s default:

    PUBLIC_NETWORK_SUBNET_V4=172.30.0.0/16
    PUBLIC_NETWORK_SUBNET_V6=<your generated prefix>/64
    TRAEFIK_BIND_V4=0.0.0.0
    TRAEFIK_BIND_V6=[::]
    COMPOSE_FILE=docker-compose.yml:network-dual-stack.yml:config/published-ports.yml:config/edge-networks.yml

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.

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.

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.

Terminal window
docker compose logs traefik 2>&1 | grep -iE "acme|cert|challenge" | tail -20

Most 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.

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.

Terminal window
# Read release notes first: https://github.com/traefik/traefik/releases
# Bump TRAEFIK_IMAGE in .env, then:
bash ops/scripts/render.sh
docker compose pull
docker compose up -d # recreates Traefik: every routed stack pauses ~7 s
docker compose logs traefik 2>&1 | grep -i error # should be empty

Never jump major versions without reading the migration guide — the configuration schema changes.