← Back to DevBytes

Docker Compose with Traefik Reverse Proxy: Production Guide

What Is Traefik?

Traefik is a modern, dynamic reverse proxy and load balancer built specifically for containerized environments and microservice architectures. Unlike traditional reverse proxies such as Nginx or HAProxy that rely on static configuration files, Traefik discovers services automatically by listening to container orchestration platforms like Docker, Kubernetes, or Consul. When a new container spins up with the right labels, Traefik detects it, generates routing configuration on the fly, and optionally provisions TLS certificates — all without a single restart or manual config edit.

Why Traefik for Production?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In production environments, reverse proxies serve as the entry point for all incoming traffic. They handle TLS termination, routing, load balancing, rate limiting, and authentication before requests ever reach your application containers. Traefik shines in production because:

Core Concepts

Understanding Traefik's architecture is essential before diving into configuration. The system is split into two distinct configuration domains:

Traefik also introduces three key building blocks:

Project Structure

A well-organized production setup separates configuration concerns. Here is a recommended directory layout:

traefik-proxy/
├── docker-compose.yml          # Main stack definition
├── traefik/
│   ├── traefik.yml             # Static configuration
│   ├── dynamic/
│   │   ├── middlewares.yml     # Shared middlewares
│   │   └── routers-services.yml # File-based routes (optional)
│   └── certs/                  # TLS certificate storage (ACME)
│       └── acme.json           # Let's Encrypt state (must be 600 perms)
├── services/
│   └── app/
│       ├── docker-compose.yml  # Per-service stack (optional)
│       └── ...
└── .env                        # Environment variables

The acme.json file is where Traefik stores all Let's Encrypt account data and certificates. It must have permissions set to 600 — Traefik enforces this and will refuse to start otherwise. Create it ahead of time with:

mkdir -p traefik/certs
touch traefik/certs/acme.json
chmod 600 traefik/certs/acme.json

Docker Compose Configuration — The Foundation

Below is a production-grade docker-compose.yml that starts Traefik as the central reverse proxy. It mounts the Docker socket (read-only for security), binds the static config file, and mounts the dynamic configuration directory and certificate storage:

# docker-compose.yml
version: "3.8"

services:
  traefik:
    image: traefik:v3.1.0
    container_name: traefik
    restart: unless-stopped
    networks:
      - proxy-net
    ports:
      - "80:80"          # HTTP entrypoint
      - "443:443"        # HTTPS entrypoint
      - "8080:8080"      # Dashboard / API (restrict in production!)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro   # Read-only Docker socket
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - ./traefik/dynamic:/etc/traefik/dynamic:ro
      - ./traefik/certs:/etc/traefik/certs
    environment:
      - CF_API_EMAIL=${CF_API_EMAIL:?Cloudflare email required}
      - CF_API_KEY=${CF_API_KEY:?Cloudflare API key required}
      - CF_API_TOKEN=${CF_API_TOKEN:-}
    depends_on:
      - cert-renewer   # Optional: periodic cert renewal watchdog

  cert-renewer:
    image: alpine:3.19
    container_name: cert-renewer
    restart: unless-stopped
    command: |
      sh -c '
        while true; do
          sleep 12h;
          echo "Triggering certificate renewal check...";
          curl -s -o /dev/null -w "%{http_code}" http://traefik:8080/api/http/challenge/status;
        done'
    networks:
      - proxy-net

networks:
  proxy-net:
    name: proxy-net
    external: false
    driver: bridge
    attachable: true

Key points about this setup:

Traefik Static Configuration

The static configuration file traefik.yml defines entrypoints, providers, the API dashboard, and the ACME certificate resolver. This is mounted read-only into the container at /etc/traefik/traefik.yml:

# traefik/traefik.yml
# Traefik v3.1 Static Configuration

# ----- Entrypoints -----
entryPoints:
  web:
    address: ":80"
    http:
      # Global HTTP → HTTPS redirect
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true
    forwardedHeaders:
      trustedIPs:
        - "10.0.0.0/8"
        - "172.16.0.0/12"
        - "192.168.0.0/16"

  websecure:
    address: ":443"
    http:
      tls:
        certResolver: cloudflare-resolver
        domains:
          - main: "example.com"
            sans:
              - "*.example.com"
              - "*.api.example.com"
    forwardedHeaders:
      trustedIPs:
        - "10.0.0.0/8"
        - "172.16.0.0/12"
        - "192.168.0.0/16"

  dashboard:
    address: ":8080"
    # Only bind to localhost in production if no VPN/whitelisting
    # address: "127.0.0.1:8080"

# ----- Providers -----
providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false          # CRITICAL: Only expose labeled containers
    network: proxy-net               # Default network for service discovery
    watch: true                      # Continuously watch for container changes
    defaultRule: "Host(`{{ index .Labels \"traefik.service.host\" }}`)" 

  file:
    directory: "/etc/traefik/dynamic"
    watch: true

# ----- API & Dashboard -----
api:
  dashboard: true
  # debug: false  # Disable debug in production

# ----- Metrics -----
metrics:
  prometheus:
    addEntryPointsLabels: true
    addServicesLabels: true
    buckets:
      - 0.1
      - 0.3
      - 0.5
      - 1.0
      - 3.0
      - 5.0

# ----- Logging -----
log:
  level: INFO          # Use DEBUG for troubleshooting, INFO for production
  format: json         # Structured logging for ingestion into log aggregators
  filePath: "/dev/stdout"

accessLog:
  format: json
  fields:
    defaultMode: keep
    headers:
      defaultMode: drop
      names:
        User-Agent: keep
        Content-Type: keep
        X-Request-ID: keep

# ----- ACME / Let's Encrypt -----
certificatesResolvers:
  cloudflare-resolver:
    acme:
      email: "ops@example.com"
      storage: "/etc/traefik/certs/acme.json"
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "1.0.0.1:53"
      # Optional: staging for testing (remove for production)
      # caServer: "https://acme-staging-v02.api.letsencrypt.org/directory"

# ----- Global Rate Limiting (optional safety net) -----
# Apply conservative limits to prevent runaway services

Critical production decisions in this config:

Dynamic Configuration via File Provider

While Docker labels handle most routing, some configurations are cleaner in files — especially shared middlewares and fallback routes. Create a dynamic configuration file:

# traefik/dynamic/middlewares.yml
http:
  middlewares:
    # ----- Security Headers (apply to all production services) -----
    security-headers:
      headers:
        frameDeny: true
        sslRedirect: true
        browserXssFilter: true
        contentTypeNosniff: true
        stsIncludeSubdomains: true
        stsPreload: true
        stsSeconds: 31536000
        customResponseHeaders:
          X-Robots-Tag: "noindex, nofollow"
          X-Content-Type-Options: "nosniff"
        referrerPolicy: "strict-origin-when-cross-origin"

    # ----- Rate Limiter (burst-capable) -----
    rate-limit-api:
      rateLimit:
        average: 100
        period: 1s
        burst: 50

    # ----- Compression (for text-heavy APIs) -----
    gzip-compress:
      compress:
        excludedContentTypes:
          - "text/event-stream"

    # ----- Redirect www → non-www (or vice versa) -----
    redirect-www-to-root:
      redirectRegex:
        regex: "^https?://www\\.(.+)"
        replacement: "https://${1}"
        permanent: true

    # ----- Basic Auth for staging / admin routes -----
    staging-auth:
      basicAuth:
        users:
          - "admin:$2y$10$hOZxXyCpVqKvqVq..."
        realm: "Staging Environment"
        headerField: "X-WebAuth-User"

    # ----- Retry on transient failures -----
    retry-transient:
      retry:
        attempts: 3
        initialInterval: "100ms"

These middlewares can be referenced by name in Docker labels or other file-based routers, keeping your service configurations DRY.

Deploying Services with Docker Compose Labels

Basic Web Service

Here is a simple Nginx service that demonstrates the minimal labels required to route traffic through Traefik with automatic HTTPS:

# services/web/docker-compose.yml
version: "3.8"

services:
  web-app:
    image: nginx:alpine
    container_name: web-app
    restart: unless-stopped
    networks:
      - proxy-net
    volumes:
      - ./html:/usr/share/nginx/html:ro
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy-net"
      
      # HTTP router (redirects to HTTPS automatically)
      - "traefik.http.routers.web-app.entrypoints=web"
      - "traefik.http.routers.web-app.rule=Host(`example.com`) || Host(`www.example.com`)"
      - "traefik.http.routers.web-app.middlewares=redirect-www-to-root@file,security-headers@file"
      
      # HTTPS router
      - "traefik.http.routers.web-app-secure.entrypoints=websecure"
      - "traefik.http.routers.web-app-secure.rule=Host(`example.com`)"
      - "traefik.http.routers.web-app-secure.tls=true"
      - "traefik.http.routers.web-app-secure.tls.certresolver=cloudflare-resolver"
      - "traefik.http.routers.web-app-secure.middlewares=security-headers@file,gzip-compress@file"
      
      # Service definition
      - "traefik.http.services.web-app-service.loadbalancer.server.port=80"
      - "traefik.http.services.web-app-service.loadbalancer.healthcheck.path=/health"
      - "traefik.http.services.web-app-service.loadbalancer.healthcheck.interval=30s"
      - "traefik.http.services.web-app-service.loadbalancer.healthcheck.timeout=3s"

networks:
  proxy-net:
    name: proxy-net
    external: true

Notice the network proxy-net is declared as external: true — it was already created by the main Traefik compose file. All routable services must be attached to this network so Traefik can reach them.

Service with Path-Based Routing and Multiple Ports

For a backend API that exposes both an HTTP API and a WebSocket endpoint on different ports:

# services/api/docker-compose.yml
version: "3.8"

services:
  api:
    image: my-api:latest
    container_name: api-backend
    restart: unless-stopped
    networks:
      - proxy-net
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy-net"
      
      # Main API router
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.rule=Host(`api.example.com`) && PathPrefix(`/v2`)"
      - "traefik.http.routers.api.tls=true"
      - "traefik.http.routers.api.tls.certresolver=cloudflare-resolver"
      - "traefik.http.routers.api.middlewares=rate-limit-api@file,security-headers@file,retry-transient@file"
      - "traefik.http.routers.api.priority=100"
      
      # API service (HTTP)
      - "traefik.http.services.api-service.loadbalancer.server.port=3000"
      - "traefik.http.services.api-service.loadbalancer.sticky.cookie.name=api_session"
      - "traefik.http.services.api-service.loadbalancer.sticky.cookie.secure=true"
      - "traefik.http.services.api-service.loadbalancer.healthcheck.path=/v2/healthz"
      
      # WebSocket router (same host, different path)
      - "traefik.http.routers.api-ws.entrypoints=websecure"
      - "traefik.http.routers.api-ws.rule=Host(`api.example.com`) && PathPrefix(`/ws`)"
      - "traefik.http.routers.api-ws.tls=true"
      - "traefik.http.routers.api-ws.tls.certresolver=cloudflare-resolver"
      - "traefik.http.routers.api-ws.middlewares=security-headers@file"
      
      # WebSocket service (different port)
      - "traefik.http.services.api-ws-service.loadbalancer.server.port=3001"

networks:
  proxy-net:
    name: proxy-net
    external: true

Here sticky.cookie enables session persistence — essential for stateful API backends. The priority field resolves ambiguity when multiple routers could match a request; higher values win.

Middleware Deep Dive

IP Whitelisting for Admin Panels

Protect internal dashboards by restricting access to VPN or office IP ranges:

labels:
  - "traefik.http.routers.admin-panel.middlewares=admin-ip-whitelist@docker"
  - "traefik.http.middlewares.admin-ip-whitelist.ipWhiteList.sourceRange=10.0.0.0/8,172.16.0.0/12,203.0.113.0/24"

Rate Limiting with Burst for Public Endpoints

Rate limiting prevents abuse while allowing legitimate traffic bursts:

labels:
  - "traefik.http.middlewares.public-rate-limit.rateLimit.average=200"
  - "traefik.http.middlewares.public-rate-limit.rateLimit.period=1s"
  - "traefik.http.middlewares.public-rate-limit.rateLimit.burst=100"

The average defines sustained requests per period, while burst allows temporary spikes above the average before rejecting with HTTP 429.

Circuit Breaker for Faulty Backends

Traefik v3 supports circuit breaking to prevent cascading failures:

labels:
  - "traefik.http.middlewares.cb-api.circuitBreaker.expression=NetworkErrorRatio() > 0.5"
  - "traefik.http.middlewares.cb-api.circuitBreaker.checkPeriod=1s"
  - "traefik.http.middlewares.cb-api.circuitBreaker.fallbackDuration=10s"
  - "traefik.http.middlewares.cb-api.circuitBreaker.recoveryDuration=30s"

When more than 50% of requests to a backend fail, the circuit opens, and Traefik returns a fallback response immediately without overwhelming the failing service further. After the recovery duration elapses, the circuit half-opens to test the backend.

SSL/TLS Deep Configuration

For production, you often need more granular TLS control. Here is how to configure modern TLS options and handle multiple domains:

# Add to traefik/traefik.yml under certificatesResolvers
certificatesResolvers:
  cloudflare-resolver:
    acme:
      email: "ops@example.com"
      storage: "/etc/traefik/certs/acme.json"
      dnsChallenge:
        provider: cloudflare
        delayBeforeCheck: 10    # Seconds to wait for DNS propagation
      certificatesDuration: 2160   # 90 days in hours
      renewBeforeExpiry: 720       # Renew 30 days before expiry

# Modern TLS options (append to static config)
tls:
  options:
    default:
      minVersion: VersionTLS12
      maxVersion: VersionTLS13
      cipherSuites:
        - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
        - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
      curvePreferences:
        - CurveP521
        - CurveP384
        - CurveP256
      sniStrict: true
  stores:
    default:
      defaultGeneratedCert:
        domain:
          main: "example.com"
        subject:
          countryName: "US"
          organization: "Example Corp"

Setting sniStrict: true ensures Traefik rejects connections that don't specify a matching SNI hostname, preventing certificate mismatch leaks. The explicit cipher suites disable legacy ciphers and prioritize forward secrecy.

Production Best Practices

Full Working Example: Bringing It All Together

Below is a complete, self-contained docker-compose.yml that runs Traefik plus a sample Whoami service — perfect for testing in a staging environment before promoting to production:

# docker-compose.yml — Full Traefik + Whoami Demo
version: "3.8"

services:
  traefik:
    image: traefik:v3.1.0
    container_name: traefik
    restart: unless-stopped
    networks:
      - proxy-net
    ports:
      - "80:80"
      - "443:443"
      - "127.0.0.1:8080:8080"   # Dashboard bound to localhost only
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - ./traefik/dynamic:/etc/traefik/dynamic:ro
      - ./traefik/certs:/etc/traefik/certs
    environment:
      - CF_API_EMAIL=${CF_API_EMAIL}
      - CF_API_KEY=${CF_API_KEY}

  whoami:
    image: traefik/whoami:v1.10
    container_name: whoami
    restart: unless-stopped
    networks:
      - proxy-net
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy-net"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
      - "traefik.http.routers.whoami.tls=true"
      - "traefik.http.routers.whoami.tls.certresolver=cloudflare-resolver"
      - "traefik.http.routers.whoami.middlewares=security-headers@file"
      - "traefik.http.services.whoami-service.loadbalancer.server.port=80"

networks:
  proxy-net:
    name: proxy-net
    driver: bridge
    attachable: true

With this stack running, a request to https://whoami.example.com will be routed through Traefik, TLS-terminated with a valid Let's Encrypt certificate, and forwarded to the Whoami container — which returns headers, IP, and request metadata in its response. You can verify the dashboard at http://localhost:8080 (accessible only from the host machine since it's bound to 127.0.0.1).

Troubleshooting Common Issues

Containers not appearing in the dashboard: Verify the container is on the proxy-net network and has traefik.enable=true. Check that traefik.docker.network=proxy-net is set — Traefik uses this to know which network interface to route to. Run docker logs traefik and look for provider errors.

Certificates stuck in "pending": DNS challenge propagation takes time. Increase delayBeforeCheck to 30–60 seconds for Cloudflare. Verify your API token has zone read/write permissions. Check Traefik logs for ACME errors — common causes include invalid API credentials or rate-limiting from Let's Encrypt.

502 Bad Gateway / Gateway Timeout: Traefik is receiving requests but cannot reach the backend. Confirm the service container is healthy and listening on the port specified in loadbalancer.server.port. Check that both Traefik and the service share the same network. Use docker exec traefik wget -qO- http://<container-ip>:<port> to test connectivity from Traefik's perspective.

Middleware not applying: Middleware names are case-sensitive and must be fully qualified with the provider suffix (@file for file-based, @docker for inline Docker label definitions). A typo in the middleware name silently fails — check Traefik logs for warnings about missing middleware references.

Conclusion

Docker Compose combined with Traefik gives you a production-grade reverse proxy that discovers services automatically, manages TLS certificates hands-free, and enforces security policies through composable middleware — all while remaining completely container-native. By separating static and dynamic configuration, mounting the Docker socket read-only, using DNS-based ACME challenges, and centralizing middleware definitions, you build a system that is both secure and operationally lightweight. The patterns in this guide scale from a single-host hobby project to a multi-node cluster with distributed certificate storage. Start with the full working example, iterate on your own services, and lean on Traefik's dashboard and structured logs to understand exactly how traffic flows through your infrastructure. With careful attention to the best practices outlined here, you will have a reverse proxy layer that is resilient, observable, and nearly zero-maintenance.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles