Traefik v3 Core Knowledge
Core Concepts
┌─────────────────────────────────────────────────────────────────┐
│ PROVIDERS ENTRYPOINTS ROUTERS SERVICES │
│ ────────── ─────────── ─────── ──────── │
│ Docker labels → :80 (web) → Host rule → LB pool │
│ File provider → :443 (websecure) PathPrefix backend │
│ Kubernetes → :8080 (dashboard) Headers servers │
└─────────────────────────────────────────────────────────────────┘
- Provider: Where Traefik reads configuration (Docker, file, Kubernetes, Consul…)
- Entrypoint: Network port + protocol that Traefik listens on
- Router: Matches requests by rule (Host, PathPrefix, Header) → sends to a service
- Middleware: Transforms requests/responses between router and service
- Service: The upstream backend (load balancer with one or more servers)
Static Config — traefik.yml
Static config defines infrastructure-level settings. Requires restart to change.
# /etc/traefik/traefik.yml (or mounted at /traefik.yml in Docker)
# Global settings
global:
checkNewVersion: false
sendAnonymousUsage: false
# API & dashboard
api:
dashboard: true
insecure: false # NEVER true in production
# Entrypoints
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
middlewares:
- security-headers@file # Apply to all HTTPS routes
metrics:
address: ":8082"
# Certificate resolvers
certificatesResolvers:
letsencrypt:
acme:
email: devops@company.com
storage: /letsencrypt/acme.json # Persistent volume required
# HTTP challenge (default) — requires port 80 open
httpChallenge:
entryPoint: web
letsencrypt-dns:
acme:
email: devops@company.com
storage: /letsencrypt/acme-dns.json
dnsChallenge:
provider: cloudflare # Set CF_DNS_API_TOKEN env var
delayBeforeCheck: 30 # Wait for DNS propagation
# Providers
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false # IMPORTANT: require explicit opt-in
network: traefik-public # Default network for container comms
file:
directory: /etc/traefik/dynamic/ # Watch for changes automatically
watch: true
# Logging
log:
level: INFO # DEBUG | INFO | WARN | ERROR
filePath: /var/log/traefik/traefik.log
# Access logs
accessLog:
filePath: /var/log/traefik/access.log
bufferingSize: 100
fields:
headers:
defaultMode: drop
names:
User-Agent: keep
X-Forwarded-For: keep
# Metrics
metrics:
prometheus:
entryPoint: metrics
addServicesLabels: true
addRoutersLabels: true
Docker Compose — Full Example
# docker-compose.yml
version: "3.9"
services:
traefik:
image: traefik:v3.3
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "80:80"
- "443:443"
volumes:
- /etc/traefik/traefik.yml:/traefik.yml:ro
- /etc/traefik/dynamic/:/etc/traefik/dynamic/:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-letsencrypt:/letsencrypt
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
networks:
- traefik-public
labels:
- "traefik.enable=true"
# Dashboard router
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=dashboard-auth"
# Dashboard basic auth: echo $(htpasswd -nbB admin 'password') | sed -e s/\\$/\\$\\$/g
- "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$hash..."
# Example application
api:
image: myapp/api:1.4.2
restart: unless-stopped
networks:
- traefik-public
- internal
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
- "traefik.http.routers.api.middlewares=rate-limit,security-headers"
# Service port (required when container exposes multiple ports)
- "traefik.http.services.api.loadbalancer.server.port=3000"
# Health check
- "traefik.http.services.api.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=10s"
- "traefik.http.services.api.loadbalancer.healthcheck.timeout=3s"
# Frontend app with path-based routing
frontend:
image: myapp/frontend:2.1.0
restart: unless-stopped
networks:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=Host(`example.com`) || Host(`www.example.com`)"
- "traefik.http.routers.frontend.entrypoints=websecure"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.routers.frontend.middlewares=www-redirect,security-headers"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
db:
image: postgres:16-alpine
restart: unless-stopped
networks:
- internal # Not on traefik-public — no external routing
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
networks:
traefik-public:
external: true # Pre-created: docker network create traefik-public
internal:
driver: bridge
volumes:
traefik-letsencrypt:
postgres-data:
Dynamic Config — File Provider
# /etc/traefik/dynamic/middlewares.yml
http:
middlewares:
# HTTP → HTTPS redirect (also configured at entrypoint level above)
redirect-to-https:
redirectScheme:
scheme: https
permanent: true
# HSTS + security headers
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
frameDeny: true
customResponseHeaders:
X-Powered-By: ""
Server: ""
# Rate limiting
rate-limit:
rateLimit:
average: 100 # Requests per second (average)
burst: 50 # Burst allowance
period: 1m # Window period
# Strip /api prefix before forwarding
strip-api-prefix:
stripPrefix:
prefixes:
- "/api"
# Add /v1 prefix
add-v1-prefix:
addPrefix:
prefix: "/v1"
# Basic auth
internal-auth:
basicAuth:
usersFile: /etc/traefik/users.htpasswd
removeHeader: true # Strip Authorization before passing to upstream
# IP whitelist (Traefik v3: use ipAllowList)
office-only:
ipAllowList:
sourceRange:
- "10.0.0.0/8"
- "203.0.113.42/32"
# Retry on failure
retry-middleware:
retry:
attempts: 3
initialInterval: 100ms
# www redirect
www-redirect:
redirectRegex:
regex: "^https?://www\\.example\\.com/(.*)"
replacement: "https://example.com/${1}"
permanent: true
# Circuit breaker
circuit-breaker:
circuitBreaker:
expression: "ResponseCodeRatio(500, 600, 0, 600) > 0.25 || NetworkErrorRatio() > 0.10"
# /etc/traefik/dynamic/services.yml — external services (not in Docker)
http:
services:
legacy-monolith:
loadBalancer:
servers:
- url: "http://192.168.1.10:8080"
- url: "http://192.168.1.11:8080"
healthCheck:
path: /status
interval: 15s
timeout: 5s
sticky:
cookie:
name: SERVERID
secure: true
httpOnly: true
routers:
legacy:
rule: "Host(`legacy.example.com`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: legacy-monolith
middlewares:
- security-headers
- rate-limit
Middleware Composition Example
Middlewares are applied in the order they are listed on the router label.
Request → rate-limit → ip-allowlist → strip-api-prefix → upstream
Response ← security-headers (applied on response) ←──────────────
# Labels on a service container
- "traefik.http.routers.myapp.middlewares=rate-limit@file,office-only@file,strip-api-prefix@file,security-headers@file"
The @file suffix means the middleware is defined in the file provider.
Use @docker for middlewares defined via labels on another container.
TCP Routing (non-HTTP)
# /etc/traefik/dynamic/tcp.yml
tcp:
routers:
postgres:
rule: "HostSNI(`*`)" # TCP passthrough (no TLS inspection)
entryPoints:
- postgres # entryPoint address: ":5432"
service: postgres-backend
services:
postgres-backend:
loadBalancer:
servers:
- address: "10.0.1.5:5432"
Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|---|---|
api.insecure: true in production | Dashboard exposed on port 8080 with no auth | Set api.insecure: false; expose dashboard via router with basicAuth middleware |
exposedByDefault: true in Docker provider | Every container automatically gets a route, including databases | Always set exposedByDefault: false; use traefik.enable=true label only where needed |
| Wildcard cert with HTTP challenge | HTTP challenge cannot prove DNS control for wildcards | Use dnsChallenge provider for wildcard domains |
| Not pinning Traefik version | traefik:latest can introduce breaking changes | Pin to exact version: traefik:v3.3.4 |
Missing traefik.http.services.<name>.loadbalancer.server.port when container exposes multiple ports | Traefik picks wrong port arbitrarily | Always specify the service port label explicitly |
Storing acme.json on a non-persistent volume | Certificates lost on container restart → rate limit hit | Mount acme.json on a named Docker volume or bind mount on the host |
| Applying heavy middlewares (auth, rate-limit) only on some routes | Inconsistent security posture | Apply security-headers globally via entrypoint middleware; add auth/rate-limit to sensitive routers |
| Router rules without priority on overlapping paths | Non-deterministic routing when multiple rules match | Add priority label: traefik.http.routers.myapp.priority=10 (higher wins) |
| Docker socket mounted as read-write with no protection | Container escape via Docker API | Use socket proxy (e.g., tecnativa/docker-socket-proxy) to restrict API access |
| No health checks on services | Traefik routes to unhealthy containers | Configure healthcheck.path, interval, and timeout on the loadbalancer |
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Router not matching — 404 from Traefik | Label typo, wrong entrypoint name, container not on Traefik network | Check traefik.http.routers.<name>.rule label; verify container is on traefik-public network; check Traefik dashboard |
| Certificate not issued | Port 80 blocked (HTTP challenge) or DNS credentials wrong (DNS challenge) | curl http://example.com/.well-known/acme-challenge/test from external; check CF_DNS_API_TOKEN |
| Middleware not applied | @file vs @docker suffix mismatch, or middleware name typo | Use exact name@provider syntax; restart Traefik after file provider changes |
| "404 page not found" from upstream app | App routing issue, not Traefik | Bypass Traefik: curl http://container_ip:port/path directly from inside Docker network |
| Dashboard shows router but requests still fail | Middleware blocking (auth, IP whitelist) | Check access log; temporarily remove middlewares to isolate |
acme.json has wrong permissions | Let's Encrypt client refuses to read file | chmod 600 acme.json — file must be readable only by owner |
| TLS certificate is self-signed (Traefik default) | ACME not configured, or cert resolver name wrong on router | Ensure tls.certresolver=<name> label matches exactly the certificatesResolvers.<name> key in static config |
| Container restarts but keeps same cert | acme.json not updated | Ensure acme.json volume is persistent; delete acme.json and restart to force re-issue (if not rate-limited) |
| High memory usage | Too many access log entries buffered | Increase bufferingSize or write to syslog; use log rotation |
| Sticky sessions not working | Cookie not forwarded by load balancer | Ensure sticky.cookie.name is set and that browsers allow cookies from the domain |
Production Checklist
- Traefik version pinned (not
latest) -
exposedByDefault: falsein Docker provider -
api.insecure: false— dashboard behind router + basicAuth - HTTP → HTTPS redirect on web entrypoint
-
acme.jsonon persistent named volume, permissions600 - Security headers middleware applied globally or on all routers
- Rate limiting middleware on public-facing routers
- Health checks on all load balancer services
- Docker socket exposed via socket proxy (not raw
/var/run/docker.sock) - Access logs enabled with rotation
- Prometheus metrics endpoint restricted to internal network
- All middleware names use correct
@providersuffix