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

135 lines
4.3 KiB
YAML

# Traefik dynamic configuration.
#
# This file is SECRET-FREE and is committed to git. The CrowdSec bouncer, which
# is the only middleware needing a credential, is defined as a Docker label in
# docker-compose.yml so its key stays in .env (gitignored).
http:
routers:
# Router for Gogs (if it's running outside Docker or on different network)
# Disabled - now using Gitea with Docker labels
# gogs:
# rule: "Host(`git.pkartchner.com`)"
# entryPoints:
# - https
# service: gogs
# middlewares:
# - geoblock
# - secure-headers
# - crowdsec-bouncer
# tls:
# certResolver: letsencrypt
# Pi-hole admin UI - internal only, hosted on 10.20.10.3
pihole:
rule: "Host(`dns.pkartchner.com`)"
entryPoints:
- https
service: pihole
middlewares:
- internal-whitelist
- secure-headers
tls:
certResolver: letsencrypt
# Technitium DNS UI - internal only, hosted on 10.20.10.3
technitium:
rule: "Host(`technitium.pkartchner.com`)"
entryPoints:
- https
service: technitium
middlewares:
- internal-whitelist
- secure-headers
tls:
certResolver: letsencrypt
services:
# Service for Gogs
# Disabled - now using Gitea with Docker labels
# gogs:
# loadBalancer:
# servers:
# - url: "http://gogs.pkartchner.com:3000"
# Pi-hole on 10.20.10.3
pihole:
loadBalancer:
servers:
- url: "http://10.20.10.3:80"
# Technitium on 10.20.10.3
technitium:
loadBalancer:
servers:
- url: "http://10.20.10.3:5380"
middlewares:
# Security headers — applied to every public site router.
secure-headers:
headers:
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customFrameOptionsValue: "SAMEORIGIN"
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "same-origin"
# Restrict a router to the internal LAN only.
internal-whitelist:
ipAllowList:
sourceRange:
- "10.20.10.0/24"
- "10.20.140.0/24"
- "127.0.0.1/32"
# NOTE: crowdsec-bouncer is deliberately NOT defined here.
#
# It carries an API key, and Traefik does not interpolate environment
# variables in this file — so defining it here would force a secret into
# version control, or force this whole file to be gitignored (which is what
# previously happened, costing the repo its "config lives in git" property).
#
# It is defined as a Docker label on the traefik container instead, where
# compose substitutes ${CROWDSEC_LAPI_KEY} from .env.
#
# Reference it as `crowdsec-bouncer@docker`, NOT `@file`.
# This file is therefore secret-free and safe to commit.
# GeoIP blocking — allow US traffic only.
geoblock:
plugin:
geoblock:
silentStartUp: false
allowLocalRequests: true
logLocalRequests: false
logAllowedRequests: true
logApiRequests: true
api: https://get.geojs.io/v1/ip/country/{ip}
apiTimeoutMs: 750
# 25 was the upstream default and is far too small: only 25 IPs are
# remembered, so nearly every visitor triggers a fresh lookup against
# a free third-party API, adding up to 750ms before WordPress starts
# and risking rate limits. A few thousand entries is a trivial amount
# of memory and turns the API call into a rare event.
cacheSize: 5000
forceMonthlyUpdate: true
# FAIL OPEN. With this false, a geojs.io outage or slow response makes
# every lookup "unknown" and therefore blocked — taking the site down
# for US visitors too, because of a third-party service we do not
# control sitting in the path of every request. Allowing unknowns
# means an outage degrades to "geoblocking temporarily ineffective"
# instead of "site is down". CrowdSec still blocks actual attackers.
allowUnknownCountries: true
unknownCountryApiResponse: nil
countries:
- US
# Rate limiting, used by Harbor on prod.
harbor-ratelimit:
rateLimit:
average: 100
burst: 50
period: 1m