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.