Files
traefik/traefik.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

72 lines
2.1 KiB
YAML

# Traefik static configuration — DEFAULT / PRODUCTION.
#
# Selected by TRAEFIK_STATIC_CONFIG in .env (defaults to this file).
# The dev host uses traefik.dev.yml, which differs ONLY in the ACME challenge
# type, because public port 80 forwards to prod so HTTP-01 cannot work there.
#
# NOTE: Traefik does NOT interpolate environment variables in this file. Any
# secret must be a docker-compose label instead, where compose substitutes it
# from .env. That is why the CrowdSec bouncer is defined in docker-compose.yml.
api:
dashboard: true
debug: false
experimental:
plugins:
bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
version: v1.3.5
geoblock:
moduleName: github.com/PascalMinder/geoblock
version: v0.2.8
entryPoints:
http:
address: ":80"
http:
redirections:
entryPoint:
to: https
scheme: https
permanent: true
# priority 1 keeps this redirect BELOW normal routers, which default
# to a priority derived from rule length. That is what allows basil's
# Host(`localhost`) HTTP router to keep serving without redirecting.
# Do not raise this value.
priority: 1
https:
address: ":443"
# Backends here use self-signed certs (harbor, etc). This disables verification
# of upstream TLS for ALL services — matches prod, but it is a real weakening.
# Prefer per-service serversTransport if you ever narrow this.
serversTransport:
insecureSkipVerify: true
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: traefik
httpClientTimeout: 0
file:
filename: /config.yml
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: pkartch@gmail.com
storage: acme.json
# HTTP-01: public port 80 forwards to this host, so the challenge
# reaches us directly. The dev host cannot use this — see traefik.dev.yml.
httpChallenge:
entryPoint: http
log:
level: INFO
accessLog:
filePath: "/var/log/traefik/access.log"