Adds hard-won operational knowledge from production Traefik deployments: - traefik healthcheck requires ping entryPoint - Named Docker volumes require docker cp for config edits - Entrypoint-level middleware + router declaration = double execution - YAML parse error drops entire file provider configuration - Rate limiting breaks SPA page loads (429 Too Many Requests) Also adds references/operational-audit.md for full-stack audit methodology. Signed-off-by: Magnus Hedemark <magnus919@pm.me>
11 KiB
Traefik Operational Audit
Full-stack audit methodology for evaluating a running Traefik deployment. Covers what to check, which commands to run, and how to classify findings.
Audit Flow
Run these phases in order. Each builds on the previous. Shortcut: Start with Phase 4 — /api/rawdata returns the complete runtime state (routers, middlewares, services, TCP middlewares) in one JSON blob. Cross-reference what's actually running against what the static/dynamic config claims, then work backward through phases to trace discrepancies. A full audit takes ~15 minutes starting from rawdata vs ~40 minutes starting from static config — and catches runtime-only issues like duplicate middleware applications that static config review misses.
Phase 1 — Surface Inventory
Identify what's running and where config lives.
# What's running
ssh $HOST "docker ps --filter name=traefik --format '{{.Names}} {{.Image}} {{.Status}}'"
# Compose file location
ssh $HOST "docker compose ls --format json | python3 -c \"import json,sys; [print(p['Name'], p['ConfigFiles']) for p in json.load(sys.stdin) if 'traefik' in p['Name'].lower()]\""
# Restart policy and mounts
ssh $HOST "docker inspect traefik --format '{{json .HostConfig.RestartPolicy}}'"
ssh $HOST "docker inspect traefik --format '{{json .Mounts}}' | python3 -m json.tool"
Phase 2 — Static Configuration
Pull the full static config from the running container.
ssh $HOST "docker exec traefik cat /etc/traefik/traefik.yml"
Key things to check:
exposedByDefault: false(should be false)api.insecure: true— if true, check whether port 8080 is mapped in compose. Even if not mapped, preferfalseunless debuggingapi.dashboard: true— check whether auth middleware is applied to the dashboard routerlog.level— should beinfoorwarnin production, notdebugcertificatesResolvers— DNS-01 preferred for homelab/internal services- Entrypoint-level middlewares — apply baseline protection (security headers, rate limiting) to all routers
forwardedHeaders.trustedIPs— should include LAN and any upstream proxy ranges
Phase 3 — Dynamic Configuration
List all files loaded by the file provider and pull their contents.
# List dynamic config files
ssh $HOST "docker exec traefik ls /etc/traefik/dynamic/"
# Pull all dynamic configs
ssh $HOST "docker exec traefik sh -c 'for f in /etc/traefik/dynamic/*.yml /etc/traefik/dynamic/*.yaml; do echo \"--- \$f ---\"; cat \$f; done'"
Key things to check:
- Extension-less files are silently ignored —
providers.file.directoryonly watches.yml,.yaml, and.toml. A file nameddefault(no extension) is dead config. Check for these:ls /etc/traefik/dynamic/ | grep -v '\\.'. If found, either delete or rename with an extension. - Naming collisions across files — if two files define the same middleware name (e.g.,
defaultas a chain inconfig.ymlanddefaultas bare headers in another file), the last file alphabetically wins and silently overwrites the earlier definition. This can replace a middleware chain with a single middleware. Cross-reference middleware names across all dynamic files. - Duplicate router names across files — undefined behavior
- Middleware definitions — chains, forwardAuth, rate limiting, security headers
- Backend URLs — hardcoded IPs are single points of failure
- Inconsistent indentation — doesn't affect functionality but signals maintenance debt
Phase 4 — Runtime State via API
Query the API for the actual running router/middleware/service state. Prefer /api/rawdata — it returns a single JSON blob with all routers, middlewares, services, and TCP middlewares. This is the single most useful endpoint for auditing and resolves ambiguity from static/dynamic config review.
# Complete runtime state (routers, middlewares, services, TCP middlewares)
ssh $HOST "docker exec traefik wget -qO- http://localhost:8080/api/rawdata | python3 -m json.tool"
# Inspect a specific router's effective middlewares
ssh $HOST "docker exec traefik wget -qO- http://localhost:8080/api/http/routers/NAME@file | python3 -m json.tool"
# List all middlewares and their usedBy routers
ssh $HOST "docker exec traefik wget -qO- http://localhost:8080/api/rawdata | python3 -c \"
import json, sys
data = json.load(sys.stdin)
for name, mw in data.get('middlewares', {}).items():
used = mw.get('usedBy', [])
print(f'{name} → used by: {used[:5]}{\"...\" if len(used) > 5 else \"\"}' if used else f'{name} → unused')
\""
Duplicate middleware detection: Look for router middleware arrays like ["default@file","default@file"]. This means the same middleware is applied both at the entrypoint level (static config) AND via Docker labels or file config on the specific router — the chain executes twice. This wastes cycles and, for rate limiting, doubles the effective limits. The fix: remove the explicit declaration from the service/router config; the entrypoint already provides it.
Phase 5 — Logs
Check for errors, warnings, and patterns.
# Recent logs
ssh $HOST "docker logs traefik --tail 50"
# Filter errors and warnings
ssh $HOST "docker logs traefik --since 24h 2>&1 | grep -E 'level\":\"(error|warn)'"
Key patterns to recognize:
maxResponseBodySize is not configured— forwardAuth middleware missing size limit; DoS vectorcontext deadline exceededon health checks — timeout too short or endpoint doesn't respondserver misbehavingon DNS lookups — container DNS misconfigurationcontext canceledon forwardAuth calls — usually client-side disconnect, not a server issue (check container uptime before investigating)Error calling http://oauth:4181— forwardAuth backend unreachable; check container health
Phase 6 — Live Verification
Test critical endpoints directly.
# Dashboard accessibility (should return 401 if auth is configured)
ssh $HOST "curl -sk -o /dev/null -w '%{http_code}' https://traefik.$DOMAIN/dashboard/"
# Health check endpoints for services with health checks configured
ssh $HOST "curl -sv --connect-timeout 5 --max-time 15 http://$BACKEND_IP:$PORT/health 2>&1"
# DNS from inside the container
ssh $HOST "docker exec traefik wget -qO- --timeout=5 https://update.traefik.io 2>&1"
Classification Framework
| Grade | Criteria |
|---|---|
| HIGH | Security exposure (no auth on sensitive endpoints), active DoS vectors, duplicate router definitions |
| MEDIUM | Failing health checks, DNS issues, intermittent errors, stale backups |
| LOW | Cosmetic (indentation, stale password files, commented-out features) |
Rule of thumb for LOW findings: Ask "does this cause operational harm or security exposure right now?" If no, it's LOW or skip it entirely. A 4-year-old .htpasswd file is not a vulnerability — it's a note. Access logs being disabled is a choice, not a bug.
What to skip
These are NOT problems:
tls.yml.oldor backup files outside thedynamic/directory (not loaded by file provider).htpasswdfile age (unless credentials are compromised)- Inconsistent indentation in YAML (zero functional impact)
- Disabled access logs or tracing (intentional configuration choice)
acme.jsonmodification date within 60 days (LE renews at 30 days before expiry)- Multiple containers with same labels during rolling deploys (zero-downtime pattern)
Middleware Audit Scoring
When rating a middleware configuration, score three independent axes. Each axis gets point values for what's present and deductions for what's missing or misconfigured.
Security (100 pts)
| Check | Points |
|---|---|
| TLS configured (ACME or static certs) | +10 |
| HSTS with subdomains + preload | +10 |
| DNS-01 ACME (no port 80 exposed) | +5 |
exposedByDefault=false on Docker provider |
+5 |
| Rate limiting at entrypoint covers all services | +10 |
| In-flight request limiting | +5 |
| OAuth/ForwardAuth on sensitive services | +10 |
| Dashboard behind auth (BasicAuth or OAuth) | +5 |
forwardedHeaders.trustedIPs limited to LAN/proxy ranges |
+5 |
| IP allowlist for internal-only endpoints | +5 |
| Deduction | Points |
|---|---|
api.insecure: true (host-mapped port 8080) |
-15 |
api.insecure: true (container-only, no host mapping) |
-8 |
| No CSP configured | -5 |
| No Permissions-Policy configured | -3 |
No frameDeny or X-Frame-Options |
-3 |
| Access logs disabled | -3 |
| No CrowdSec/fail2ban threat intelligence | -3 |
Performance (100 pts)
| Check | Points |
|---|---|
| HTTP/3 enabled | +10 |
| Compression (gzip + brotli + zstd) | +15 |
| Rate limiting prevents abuse without blocking LAN | +10 |
| In-flight request limiting | +10 |
| Prometheus metrics with custom buckets | +10 |
| JSON structured logging | +5 |
| Health checks on services | +5 |
| CurveP256 preferred over CurveP384 (2-4x faster, equivalent security) | +10 |
| Deduction | Points |
|---|---|
No serversTransport tuning (pool size, timeouts) |
-5 |
| No keep-alive tuning | -3 |
| CurveP384 preferred without CurveP256 (slower handshakes) | -5 |
| No passive health checks on most services | -2 |
Correctness (100 pts)
| Check | Points |
|---|---|
| Entrypoint-level middleware applied consistently | +15 |
| Chain order correct (headers → compress → rate-limit → inflightreq) | +15 |
| LAN exclusions on rate-limit and inflightreq | +10 |
| OAuth on appropriate services, not over-applied | +10 |
| Dashboard behind auth | +10 |
| TLS resolver properly configured and referenced | +10 |
| HTTP→HTTPS redirect | +5 |
| Deduction | Points |
|---|---|
Duplicate middleware in router arrays (default@file twice) |
-8 |
Dead config files (extension-less, .old in dynamic dir) |
-3 |
| Middleware defined but never used (commented out of chain) | -3 |
| Duplicate router definitions across providers | -5 |
Post-Fix Verification
After applying fixes:
- Check logs for clean state:
docker logs traefik --since 2m | grep -E 'ERROR|WARN' - Verify auth on protected endpoints:
curl -sk -o /dev/null -w '%{http_code}' $URL→ expect 401 - Verify no duplicate routers via API
- For compose label changes, run
docker compose up -dto re-apply labels (may recreate container) - For file provider changes, no restart needed —
watch: truepicks them up
DNS Debugging Pattern
If the Traefik container shows server misbehaving on DNS lookups (127.0.0.11:53):
- Check Docker daemon DNS config:
cat /etc/docker/daemon.json | python3 -m json.tool - If Tailscale or a VPN is interfering, set explicit DNS:
{"dns": ["1.1.1.1", "8.8.8.8"]} - Restart Docker after daemon.json changes
- Recreate the Traefik container to pick up new DNS settings (old containers may cache bad DNS)
- Verify:
docker exec traefik wget -qO- --timeout=5 https://update.traefik.io