Document running RosterChief beside another domain on one Caddy

The "behind an existing Caddy" section assumed the DNS plugin was being set up
from scratch. The realistic case is a box whose Caddy already does Cloudflare
DNS-01 for another domain, so it now covers that: set acme_dns once globally and
every site inherits it, or scope a token per zone with a snippet.

Leads with the failure that will actually happen -- a Cloudflare token is scoped to
named zones, so the existing one grants DNS:Edit on the domain it was made for and
nothing else, and the new site fails its challenge on a permissions error whose
text does not say so.

Also spells out that *.test.rosterchief.app does NOT match test.rosterchief.app: a
wildcard covers exactly one label, so leaving the bare host off the site line gives
the club subdomains a certificate and the control panel none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:22:37 +02:00
parent d30b163122
commit 98e5f22873

View File

@@ -152,14 +152,34 @@ docker compose -f compose.behind-proxy.yaml up -d # web + db + redis, n
bound to all interfaces, a test instance is reachable at `http://<server-ip>:8001` with no
TLS, bypassing the proxy and every security header with it.
Then, in the host's Caddyfile:
Then add a site block to the host's Caddyfile. Caddy serves any number of domains on the same
ports — TLS is chosen per connection by SNI — so a second (or tenth) site is just another
block.
### If that Caddy already does Cloudflare DNS-01
Which is the usual case: the box has a domain on Cloudflare and Caddy already has the DNS
plugin. Then set the challenge **once, globally**, and every site inherits it — no `tls`
block per site, and wildcards simply work:
```caddy
test.rosterchief.app, *.test.rosterchief.app {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
{
email you@example.com
# Applies DNS-01 to every site below.
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# --- whatever the box already serves --------------------------------------
existing-thing.example.com {
reverse_proxy 127.0.0.1:3000
}
# --- RosterChief test instance --------------------------------------------
# The bare host AND the wildcard, on one certificate.
test.rosterchief.app, *.test.rosterchief.app {
encode zstd gzip
reverse_proxy 127.0.0.1:8001 {
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
@@ -167,20 +187,82 @@ test.rosterchief.app, *.test.rosterchief.app {
}
```
Three things this needs, and each one is a way to lose an afternoon:
### If the two domains need different tokens
1. **The host's Caddy must have the DNS plugin too.** The wildcard is still a wildcard: the
stock `caddy` package cannot answer a DNS-01 challenge. `caddy add-package
github.com/caddy-dns/cloudflare` on a package install, or run a Caddy built like
`deploy/caddy/Dockerfile`.
2. **Give the test instance its own subdomain tree** (`*.test.rosterchief.app`) and set
`ROSTERCHIEF_BASE_DOMAIN=test.rosterchief.app`. It drives tenant resolution, the shared
session cookie *and* the WebAuthn RP ID — point it at the production domain and test
passkeys start colliding with real ones.
3. **`header_up X-Forwarded-Proto` is not optional**, exactly as in the bundled Caddyfile.
Without it Django believes the request is plain HTTP behind the proxy.
Different Cloudflare accounts, or tokens scoped per zone. Drop `acme_dns` and give each site
its own `tls`; a snippet keeps it short:
DNS still needs both records, pointing at the test box:
```caddy
{
email you@example.com
}
(cf) {
tls {
dns cloudflare {args[0]}
}
}
existing-thing.example.com {
import cf {env.CF_TOKEN_EXAMPLE}
reverse_proxy 127.0.0.1:3000
}
test.rosterchief.app, *.test.rosterchief.app {
import cf {env.CF_TOKEN_ROSTERCHIEF}
reverse_proxy 127.0.0.1:8001 {
header_up X-Forwarded-Proto {scheme}
}
}
```
### What actually goes wrong
1. **The token must cover the *new* zone.** A Cloudflare token is scoped to named zones, and
an existing one almost certainly grants `Zone:DNS:Edit` on the domain it was made for and
nothing else. The new site then fails its DNS-01 challenge on a permissions error whose
text does not say so. Widen the token, or mint a second one and use the snippet form.
2. **Both hostnames must be listed.** `*.test.rosterchief.app` does **not** match
`test.rosterchief.app` — a wildcard covers exactly one label. Leave the bare host out and
the club subdomains have a certificate while the control panel does not. Hence the comma.
(Wildcards are also only one level deep: `ajax.test.…` yes, `a.b.test.…` no.)
3. **Caddy must have the DNS plugin.** Stock `caddy` cannot answer a DNS-01 challenge at all.
`caddy add-package github.com/caddy-dns/cloudflare`, or run a Caddy built like
`deploy/caddy/Dockerfile`. (If DNS-01 already works on the box, you have it.)
4. **The token must be in *Caddy's* environment**, not your shell's — `{env.…}` reads the
process it runs in:
```ini
# /etc/systemd/system/caddy.service.d/override.conf
[Service]
EnvironmentFile=/etc/caddy/caddy.env # CLOUDFLARE_API_TOKEN=...
```
Then `systemctl daemon-reload && systemctl restart caddy`.
5. **`header_up X-Forwarded-Proto` is not optional**, exactly as in the bundled Caddyfile:
without it Django believes the request behind the proxy is plain HTTP.
6. **Give the test instance its own subdomain tree** and set
`ROSTERCHIEF_BASE_DOMAIN=test.rosterchief.app`. That variable drives tenant resolution,
the shared session cookie *and* the WebAuthn RP ID — point it at the production domain and
test passkeys start colliding with real ones.
### Applying and checking it
```bash
caddy validate --config /etc/caddy/Caddyfile # syntax and modules
systemctl reload caddy # zero downtime; existing certs untouched
journalctl -u caddy -f # watch the DNS-01 challenge
curl -I https://test.rosterchief.app/healthz
curl -I https://any-club-slug.test.rosterchief.app/ # proves the WILDCARD, not just the host
```
Reloading provisions only what is new, so the existing site's certificate is not reissued.
Allow 3060s for the DNS record to propagate before the challenge completes.
DNS needs both records, pointing at the test box:
```
A test.rosterchief.app -> <server ip>