Traefik: Reverse Proxy for Docker Without the Headache
When one website runs on Nginx on a VPS, everything is clear. When there are fifteen of them in Docker, each on its own port, and you need HTTPS on each domain — you're stuck manually managing reverse proxy configs, certbot cron jobs, and port tables in your head. Traefik solves exactly this: it watches running containers, reads their labels, and builds routes automatically. You don't touch the Traefik config when deploying a new service — it discovers it on its own.
How Traefik Differs from Nginx as a Reverse Proxy
Nginx is an excellent reverse proxy, but it's static. Add a new service — add a new server block, run reload. In a containerized environment with 10–20 services this turns into routine. Traefik is designed for dynamic environments from the ground up: it listens to the Docker socket and updates routing configuration without a restart.
The second difference — built-in Let's Encrypt. Traefik handles TLS certificate issuance and renewal through ACME (HTTP or DNS challenge), stores them, and applies them to the right routes. Certbot becomes unnecessary.
Third — a built-in dashboard that visualizes all routers, services, and middleware. More useful for debugging than parsing nginx.conf.
Basic Installation via Docker Compose
Minimal production-ready configuration with Let's Encrypt:
version: "3.8" services: traefik: image: traefik:v3.0 container_name: traefik restart: unless-stopped command: - "--api.dashboard=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" ports: - "80:80" - "443:443" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - "./letsencrypt:/letsencrypt" labels: - "traefik.enable=true" - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)" - "traefik.http.routers.dashboard.service=api@internal" - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt" networks: - traefik-public networks: traefik-public: external: true
Key detail: providers.docker.exposedbydefault=false means Traefik won't auto-publish all containers — only those with traefik.enable=true label. Without this, all containers on the server become publicly accessible.
mkdir -p letsencrypt touch letsencrypt/acme.json chmod 600 letsencrypt/acme.json docker network create traefik-public docker-compose up -d
Connecting Services via Labels
Any container in the same Docker network connects to Traefik via labels. WordPress example:
services: wordpress: image: wordpress:latest environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} labels: - "traefik.enable=true" - "traefik.http.routers.wordpress.rule=Host(`blog.example.com`)" - "traefik.http.routers.wordpress.entrypoints=websecure" - "traefik.http.routers.wordpress.tls.certresolver=letsencrypt" - "traefik.http.services.wordpress.loadbalancer.server.port=80" networks: - traefik-public - internal db: image: mysql:8.0 networks: - internal
Traefik sees the new container, reads its labels, and automatically creates a router for the domain with HTTPS. If a service listens on a non-standard port (like a Node.js app on 3000), specify it explicitly via loadbalancer.server.port — otherwise Traefik tries to guess and sometimes gets it wrong.
Middleware: Auth, Rate Limiting, Headers
Middleware is a chain of request transformations before reaching the service. Basic Auth for staging protection:
docker run --rm httpd:2.4-alpine htpasswd -nbB admin mypassword # In labels ($ is escaped as $$) - "traefik.http.middlewares.staging-auth.basicauth.users=admin:$$2y$$05$$xyz..." - "traefik.http.routers.myapp-staging.middlewares=staging-auth"
Rate limiting:
- "traefik.http.middlewares.ratelimit.ratelimit.average=100" - "traefik.http.middlewares.ratelimit.ratelimit.burst=50" - "traefik.http.middlewares.ratelimit.ratelimit.period=1m" - "traefik.http.routers.myapi.middlewares=ratelimit"
Security headers:
- "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000" - "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true" - "traefik.http.middlewares.security-headers.headers.browserXssFilter=true" - "traefik.http.middlewares.security-headers.headers.frameDeny=true"
Multiple middleware applied with a comma: traefik.http.routers.myapp.middlewares=ratelimit,security-headers
Static Configuration via Files
Labels work well for simple cases. For complex configurations, YAML files are more manageable:
# traefik.yml api: dashboard: true entryPoints: web: address: ":80" http: redirections: entrypoint: to: websecure scheme: https websecure: address: ":443" providers: docker: exposedByDefault: false file: directory: /etc/traefik/dynamic watch: true certificatesResolvers: letsencrypt: acme: email: admin@example.com storage: /letsencrypt/acme.json httpChallenge: entryPoint: web# dynamic/middlewares.yml http: middlewares: secure-headers: headers: stsSeconds: 31536000 contentTypeNosniff: true browserXssFilter: true rate-limit-api: rateLimit: average: 200 burst: 100 period: 1m
Files in the dynamic directory are watched live (watch: true) — no restart needed.
Load Balancing Across Replicas
Multiple replicas of a service — Traefik automatically load-balances round-robin. Sticky sessions for stateful apps:
- "traefik.http.services.myapp.loadbalancer.sticky.cookie=true" - "traefik.http.services.myapp.loadbalancer.sticky.cookie.name=lb_session" - "traefik.http.services.myapp.loadbalancer.sticky.cookie.secure=true"
Health check — excludes unhealthy containers from balancing:
- "traefik.http.services.myapp.loadbalancer.healthcheck.path=/health" - "traefik.http.services.myapp.loadbalancer.healthcheck.interval=10s" - "traefik.http.services.myapp.loadbalancer.healthcheck.timeout=3s"
Common Problems and Fixes
- Certificate isn't issued. Port 80 is blocked by firewall. Let's Encrypt HTTP challenge requires port 80 accessible from outside. Check: curl http://yourdomain.com/.well-known/acme-challenge/test
- Container doesn't appear in routes. Container and Traefik are in different Docker networks — most common cause
- Label applied but route doesn't work. Check the dashboard — all routers and config errors are visible there
- Corrupted acme.json. Multiple Traefik replicas without shared storage both write to one file. For HA you need Redis or Consul
- Rate limit uses X-Forwarded-For instead of real IP. Behind CDN you need to trust X-Forwarded-For: --entrypoints.websecure.forwardedHeaders.trustedIPs=1.2.3.4/32
Set up JSON logging right away:
log: level: INFO format: json accessLog: format: json fields: defaultMode: keep headers: defaultMode: drop names: User-Agent: keep X-Forwarded-For: keep
Traefik isn't a silver bullet. For one or two services, Nginx is simpler and clearer. But if you have a Docker environment with multiple services, frequent deployments, and the need for HTTPS on every domain — Traefik eliminates exactly that routine which takes time without adding any value.