Files
traefik/docker-compose.yml
Claude Code 0f76abd1fa Harden geoblock, remove secrets from config, support dev host
Refs #1

## Security

* Remove the CrowdSec LAPI key from config.yml. Traefik does not interpolate
  env vars in its YAML config, so the key had to be literal there - which is
  why the file kept drifting out of version control. It is now defined as a
  docker-compose label, where compose substitutes ${CROWDSEC_LAPI_KEY} from
  .env. config.yml is therefore secret-free and stays in git.

  NOTE: this renames the middleware crowdsec-bouncer@file -> @docker.
  Site stacks referencing @file must be updated.

  The old key remains in this repo's history and must still be rotated and
  scrubbed - removing it here does not un-expose it.

* Move the dashboard basic-auth hash out of docker-compose.yml into .env.
  In .env every '$' must be doubled to '$$', or compose silently collapses
  the hash to an empty string and the dashboard rejects every password.

* Stop publishing port 8080. The API is not insecure-mode, so nothing served
  there; publishing it only widened exposure.

## Geoblock (the availability fix)

* allowUnknownCountries: false -> true. Previously a geojs.io outage made
  every lookup "unknown" and therefore blocked, taking sites down for US
  visitors too - a third-party service in the critical path with no degraded
  mode. Now an outage degrades to "geoblocking ineffective". CrowdSec still
  blocks actual attackers.

* cacheSize: 25 -> 5000. At 25, nearly every visitor triggered a fresh API
  call, adding up to 750ms and risking rate limits.

## Dev host support

* Add traefik.dev.yml, selected by TRAEFIK_STATIC_CONFIG in .env (defaults to
  traefik.yml, so prod behaviour is unchanged). It differs from traefik.yml
  ONLY in the ACME challenge type: public port 80 forwards to prod for its
  renewals, so an HTTP-01 challenge for a dev hostname hits prod's Traefik,
  matches no router, and 404s. Dev uses DNS-01 via Route 53 instead, which
  needs no inbound connectivity.

* Add .env.example documenting every variable.

## Captured from prod's working tree

Prod had uncommitted drift; these were running but never committed:
plugin bumps (bouncer v1.3.3->v1.3.5, geoblock v0.2.7->v0.2.8), the
entryPoints HTTP->HTTPS redirect with priority 1, and httpClientTimeout: 0.

The priority: 1 on that redirect is load-bearing - it keeps the redirect
below normal routers so basil's Host(`localhost`) HTTP route still serves.
Do not raise it.

## Verified on dev (prkl10), Traefik 3.7.10

7 routers / 8 middlewares, 0 warnings, 0 errors. Certificates issued via
DNS-01. pihole and technitium routers preserved. Sites serving 200.
2026-08-05 02:09:45 -06:00

73 lines
3.4 KiB
YAML

services:
traefik:
image: traefik:latest
container_name: traefik
restart: always
security_opt:
- no-new-privileges:true
networks:
- traefik
- crowdsec
ports:
- "80:80"
- "443:443"
# Port 8080 is deliberately NOT published. The dashboard is reached
# through the authenticated router below. Publishing 8080 only exposes
# the API when `api.insecure: true`, which we do not set.
environment:
- TZ=America/Denver
# Route 53 DNS-01 challenge. Values live in .env (gitignored).
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_REGION=${AWS_REGION}
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
# Static config differs between hosts ONLY in the ACME challenge type:
# prod uses HTTP-01 (public port 80 lands there), dev must use DNS-01.
# Selected via .env so both environments share one branch instead of
# diverging permanently.
- ${TRAEFIK_STATIC_CONFIG:-./traefik.yml}:/traefik.yml:ro
- ./acme.json:/acme.json
- ./config.yml:/config.yml:ro
- ./logs:/var/log/traefik
labels:
- "traefik.enable=true"
# Dashboard — LAN-only AND password protected.
- "traefik.http.routers.traefik.rule=Host(`${TRAEFIK_DASHBOARD_HOST}`)"
- "traefik.http.routers.traefik.entrypoints=https"
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=traefik-auth,internal-whitelist@file"
# Credentials come from .env (gitignored), not from this file.
# Generate with: htpasswd -nb admin 'yourpassword'
- "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
# Named redirect middleware, referenced by site stacks on their HTTP router.
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
# CrowdSec bouncer.
#
# Defined HERE rather than in config.yml on purpose: Traefik does not
# interpolate environment variables in its YAML config files, so the API
# key would have to be written literally into config.yml — which is why
# that file was previously gitignored and the repo lost its "config lives
# in git" property. Compose DOES substitute ${...} in labels, so defining
# it here keeps the key in .env alone and lets config.yml be committed.
#
# Consequence: this middleware is `crowdsec-bouncer@docker`, NOT `@file`.
# Site stacks must reference it with the @docker suffix.
- "traefik.http.middlewares.crowdsec-bouncer.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec-bouncer.plugin.bouncer.crowdsecMode=live"
- "traefik.http.middlewares.crowdsec-bouncer.plugin.bouncer.crowdsecLapiKey=${CROWDSEC_LAPI_KEY}"
- "traefik.http.middlewares.crowdsec-bouncer.plugin.bouncer.crowdsecLapiHost=crowdsec:8080"
- "traefik.http.middlewares.crowdsec-bouncer.plugin.bouncer.crowdsecLapiScheme=http"
- "traefik.http.middlewares.crowdsec-bouncer.plugin.bouncer.forwardedHeadersCustomName=X-Custom-Header"
networks:
# Pre-create once per host: docker network create traefik
traefik:
name: traefik
external: true
crowdsec:
name: crowdsec
external: true