{ # Admin API on loopback only. It can reconfigure the proxy, so it must # not be reachable by other containers on the Docker network (a journal # / planner RCE could otherwise rewrite routes). Reloads run in-container # (`docker compose exec caddy caddy reload`) over this loopback endpoint, # matching infrastructure/brouter-host/Caddyfile. admin localhost:2019 } # Read-only Prometheus metrics on a dedicated network-reachable port, # split off the admin endpoint above so scraping doesn't require exposing # the admin API. Prometheus scrapes caddy:2020 (see prometheus.yml). :2020 { metrics /metrics } (security_headers) { header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss: https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';" } } (block_scanners) { @scanners path /.env* /.git* /wp-* /wp-admin* /admin* /config.* /backup* /.aws* /.docker* /composer* /vendor* *.php *.asp respond @scanners 403 } {$DOMAIN:trails.cool} { import security_headers import block_scanners log { output stdout format json } reverse_proxy journal:3000 { # During an `apps` deploy the journal container is briefly down # (~10–30s) while compose swaps containers. Without these, # Caddy returns 502 immediately and the `caddy-502-rate` alert # trips on every deploy. With them, Caddy holds and retries # against the upstream for up to 30s — restart becomes # invisible to clients. A real outage longer than 30s still # 502s and correctly trips the alert. lb_try_duration 30s lb_try_interval 250ms } } www.{$DOMAIN:trails.cool} { redir https://{$DOMAIN:trails.cool}{uri} permanent } grafana.internal.{$DOMAIN:trails.cool} { reverse_proxy grafana:3000 } planner.{$DOMAIN:trails.cool} { header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss: https://{$DOMAIN:trails.cool} https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';" } import block_scanners log { output stdout format json } reverse_proxy planner:3001 { # Same rationale as the journal block — see the comment there. lb_try_duration 30s lb_try_interval 250ms } } # ── Staging ────────────────────────────────────────────────────────────── # Persistent staging instance. CSP allow-lists hardcode `staging.trails.cool` # rather than `{$DOMAIN}` because the Caddy container runs with the # production DOMAIN env (`trails.cool`); staging blocks need their own domain # baked in. Upstreams are reached via `host.docker.internal` because the # staging compose project publishes its containers on the host's loopback # (127.0.0.1:3100/3101) rather than joining the production Caddy network. staging.{$DOMAIN:trails.cool} { import security_headers import block_scanners log { output stdout format json } reverse_proxy host.docker.internal:3110 { lb_try_duration 30s lb_try_interval 250ms } } planner.staging.{$DOMAIN:trails.cool} { header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" # connect-src includes wss + https://*.staging so PR-preview journals # (pr-N.staging.trails.cool) can use this shared planner. PR previews # are journal-only; this is the planner they all talk to. Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss://*.staging.{$DOMAIN:trails.cool} https://*.staging.{$DOMAIN:trails.cool} https://staging.{$DOMAIN:trails.cool} https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';" } import block_scanners log { output stdout format json } reverse_proxy host.docker.internal:3111 { lb_try_duration 30s lb_try_interval 250ms } } # Per-PR preview snippets are written by cd-staging.yml into # /etc/caddy/sites/pr-.caddyfile and picked up here on Caddy reload. The # glob is allowed to match nothing — Caddy treats an empty match as a no-op. import /etc/caddy/sites/*.caddyfile