Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Name a variable by what it *is*, never by its format (`SESSION_KEY`, not `KEY_HE
Common shared variables a target's vaults map into one or more apps' `.env`:

- `DOMAIN` - Base domain for all services
- `DNS_CHALLENGE_PROVIDER`, `DNS_CHALLENGE_TOKEN` - Optional DNS-01 ACME challenge, traefik-only (see "Traefik Integration" below)
- `DNS_CHALLENGE_PROVIDER` - Optional DNS-01 ACME challenge provider selector, traefik-only; the selected provider's own credential variable(s) are documented in "Traefik Integration" below
- `DATABASE_PASSWORD` - Shared database password
- `SESSION_KEY` - Shared session-signing key (safe to share - see above)
- `TIMEZONE` - System timezone
Expand Down Expand Up @@ -202,7 +202,11 @@ Apps are accessible at `{app-name}.{DOMAIN}` with automatic SSL. `acmeresolver`

`apps/traefik/docker-compose.yml` has no config template at all - Traefik's entire static configuration (entryPoints, the docker provider, logging, and the ACME resolver) is expressed directly as `TRAEFIK_*` environment variables, split across `x-vault-env`/`x-internal-env` like any other app. This isn't a style choice: Traefik's static config sources (file, env vars, CLI flags) are mutually exclusive, not merged - if a config file is present, environment variables for static config are silently ignored entirely, verified empirically while building this. Keeping traefik file-free sidesteps that trap.

`acmeresolver` only has `dnsChallenge` configured - a target's vault must provide `DNS_CHALLENGE_PROVIDER` (a lego provider name, e.g. `cloudflare`) and `DNS_CHALLENGE_TOKEN` (a single credential value). `httpChallenge` used to be configured unconditionally alongside it as a zero-config default, but Traefik picks http-01 whenever both challenge types are present on the same resolver, silently ignoring the configured DNS provider - that broke renewal outright on any target where the http-01 path isn't actually reachable (traffic to `/.well-known/acme-challenge/...` routed to an app container instead of Traefik's own ACME handler). See #202. `DNS_CHALLENGE_TOKEN` is mapped into every supported provider's own expected variable name at once, in its own `x-dns-challenge-tokens` anchor (separate from `x-vault-env` - it's not a direct 1:1 vault declaration, it's one vault value fanned out into several provider-specific slots): Cloudflare's `CF_DNS_API_TOKEN`, DigitalOcean's `DO_AUTH_TOKEN`, Hetzner's `HETZNER_API_TOKEN`, Linode's `LINODE_TOKEN`. lego only ever reads the one belonging to whichever provider was actually selected, so the others sit unused and harmless. This only works for providers needing a single token; add one by adding one more `x-dns-challenge-tokens` line mapping `${DNS_CHALLENGE_TOKEN:-}` into that provider's lego variable name (see [go-acme.github.io/lego/dns](https://go-acme.github.io/lego/dns/) for the full ~200-provider list and each one's required variable(s)). Providers needing more than one credential (Route53, GoDaddy, Namecheap, Selectel's current v2 API, and most others actually dominant by real market share - checked deliberately, single-token providers are the minority) don't fit this pattern and would need their own explicit, non-fanned-out vault variables instead - not yet added since nothing in the catalog needs one today.
`acmeresolver` only has `dnsChallenge` configured - a target's vault must provide `DNS_CHALLENGE_PROVIDER` (a lego provider name, e.g. `cloudflare`) and that provider's own credential variable(s). `httpChallenge` used to be configured unconditionally alongside it as a zero-config default, but Traefik picks http-01 whenever both challenge types are present on the same resolver, silently ignoring the configured DNS provider - that broke renewal outright on any target where the http-01 path isn't actually reachable (traffic to `/.well-known/acme-challenge/...` routed to an app container instead of Traefik's own ACME handler). See #202.

Every provider's credential variable(s) are declared directly in `apps/traefik/docker-compose.yml`'s `x-vault-env`, each optional and defaulting to empty (`${VAR:-}`): Cloudflare's `CF_DNS_API_TOKEN`, DigitalOcean's `DO_AUTH_TOKEN`, Hetzner's `HETZNER_API_TOKEN`, Linode's `LINODE_TOKEN`, and Selectel v2's `SELECTELV2_USERNAME`/`SELECTELV2_PASSWORD`/`SELECTELV2_ACCOUNT_ID`/`SELECTELV2_PROJECT_ID` (lego's `selectelv2` provider authenticates via Keystone/OpenStack, needing all four at once; lego's older `selectel` provider is a different, legacy Selectel DNS API taking a single `SELECTEL_API_TOKEN` instead). lego only ever reads the credential(s) belonging to whichever provider was actually selected, so the rest sit unused and harmless. A single-token provider used to share one generic `DNS_CHALLENGE_TOKEN` vault variable fanned out into each provider's slot, but that made single-token and multi-credential providers (like Selectel v2) inconsistent with each other; naming every provider's variable(s) explicitly, with no fan-out, keeps the pattern uniform regardless of how many credentials a provider needs. Add a new provider by adding its lego variable name(s) (see [go-acme.github.io/lego/dns](https://go-acme.github.io/lego/dns/) for the full ~200-provider list and each one's required variable(s)) as their own entries in `x-vault-env`.

The map *key* in `x-vault-env` (`CF_DNS_API_TOKEN`, `DO_AUTH_TOKEN`, ...) is the literal env var name the traefik container needs - lego reads it verbatim via `os.Getenv`, so it's non-negotiable, abbreviated prefix (`CF_`, `DO_`) and all. The `${VAR:-}` on the right is a different name: the app-facing variable a target's vault actually declares, free to spell the provider out in full since nothing reads it but `docker compose`'s own substitution - `CF_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN:-}`, not `${CF_DNS_API_TOKEN:-}`. Hetzner and Linode's forced key names already spell the provider in full, so their `${VAR:-}` reference matches the key as-is; only Cloudflare and DigitalOcean's lego-forced keys are actually abbreviated, so only those two need the two names to diverge. A target's own vault then maps that full app-facing name to its real GitHub Secret (see `vaults/traefik.yml`'s `CLOUDFLARE_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN}`).

`apps/cloudflared/` is a separate, optional catalog app - not part of `apps/traefik/` itself - for targets that want to front Traefik with a Cloudflare Tunnel instead of (or alongside) exposing `HTTP_PORT`/`HTTPS_PORT` directly. It's a single `cloudflared` service joined to the external `traefik` network only (no `internal` network, no traefik labels - it isn't routed *to*, it originates the connection), running `cloudflared tunnel --no-autoupdate run` with the token-based (dashboard-managed) tunnel mode: `TUNNEL_TOKEN` in the environment is all `cloudflared` needs, no local tunnel config file. Connection direction is `cloudflared` → `traefik:80` over the docker network (the tunnel's ingress rule, set in the Cloudflare Zero Trust dashboard, points at `http://traefik:80`), and Traefik then routes onward to backend services exactly as it already does for direct traffic - no change to how apps register with Traefik. Because `cloudflared` reaches Traefik over the `traefik` docker network, its connecting IP already falls inside the existing `TRAEFIK_ENTRYPOINTS_HTTPS_FORWARDEDHEADERS_TRUSTEDIPS` range (`172.16.0.0/12,10.0.0.0/8`) - no trustedIPs change needed for this topology. Deploying `cloudflared` doesn't remove Traefik's own published ports; the two paths coexist unless a target deliberately stops publishing `HTTP_PORT`/`HTTPS_PORT`.

Expand Down
15 changes: 9 additions & 6 deletions apps/traefik/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
x-vault-env: &vault-env
TRAEFIK_CERTIFICATESRESOLVERS_ACMERESOLVER_ACME_EMAIL: ${ADMIN_MAIL}
TRAEFIK_CERTIFICATESRESOLVERS_ACMERESOLVER_ACME_DNSCHALLENGE_PROVIDER: ${DNS_CHALLENGE_PROVIDER:-}
x-dns-challenge-tokens: &dns-challenge-tokens
CF_DNS_API_TOKEN: ${DNS_CHALLENGE_TOKEN:-}
DO_AUTH_TOKEN: ${DNS_CHALLENGE_TOKEN:-}
HETZNER_API_TOKEN: ${DNS_CHALLENGE_TOKEN:-}
LINODE_TOKEN: ${DNS_CHALLENGE_TOKEN:-}
CF_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN:-}
DO_AUTH_TOKEN: ${DIGITALOCEAN_AUTH_TOKEN:-}
HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-}
LINODE_TOKEN: ${LINODE_TOKEN:-}
SELECTELV2_USERNAME: ${SELECTELV2_USERNAME:-}
SELECTELV2_PASSWORD: ${SELECTELV2_PASSWORD:-}
SELECTELV2_ACCOUNT_ID: ${SELECTELV2_ACCOUNT_ID:-}
SELECTELV2_PROJECT_ID: ${SELECTELV2_PROJECT_ID:-}
x-internal-env: &internal-env
TRAEFIK_CERTIFICATESRESOLVERS_ACMERESOLVER_ACME_STORAGE: /letsencrypt/acme.json
TRAEFIK_GLOBAL_CHECKNEWVERSION: false
Expand All @@ -24,7 +27,7 @@ services:
traefik:
image: traefik
environment:
<<: [*vault-env, *dns-challenge-tokens, *internal-env]
<<: [*vault-env, *internal-env]
ports:
- "${HTTP_PORT}:80"
- "${HTTPS_PORT}:443"
Expand Down
2 changes: 1 addition & 1 deletion vaults/traefik.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ keys:
env:
ADMIN_MAIL: ${ADMIN_MAIL}
DNS_CHALLENGE_PROVIDER: ${DNS_CHALLENGE_PROVIDER}
DNS_CHALLENGE_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN}
CLOUDFLARE_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN}
HTTP_PORT: ${TRAEFIK_HTTP_PORT}
HTTPS_PORT: ${TRAEFIK_HTTPS_PORT}
Loading