← Back to DevBytes

Docker Compose with Traefik Reverse Proxy: Best Practices and Common Pitfalls

What Is Docker Compose with Traefik Reverse Proxy?

Docker Compose is the de facto tool for defining and running multi-container Docker applications. Traefik is a modern, dynamic reverse proxy and load balancer purpose-built for containerized environments. When combined, they create a powerful stack where Traefik automatically discovers services running in Docker Compose, routes traffic to them, handles TLS termination, and applies middleware—all with minimal manual configuration.

Unlike traditional reverse proxies (nginx, Apache) that require explicit configuration files and reloads, Traefik listens to Docker socket events and updates its routing table in real time as containers start, stop, or change labels. This means you can spin up a new microservice with just a few Docker Compose labels, and Traefik instantly routes traffic to it—no config file edits, no reloads, no downtime.

Why Traefik Matters for Docker Compose Deployments

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

The traditional approach to reverse proxying in Docker involves manually editing nginx config files, finding container IPs, and dealing with port mapping nightmares. Traefik eliminates these pain points entirely:

Setting Up the Basics

Let's build a complete, production-ready Traefik + Docker Compose setup from scratch. We'll start with the core infrastructure and then add example services.

Project Structure

project/
├── docker-compose.yml
├── traefik/
│   ├── traefik.yml          # Static configuration
│   ├── dynamic/
│   │   └── middlewares.yml   # Dynamic configuration (optional)
│   └── certs/
│       └── acme.json         # Let's Encrypt certificate store
└── services/
    └── whoami/               # Example backend service

Traefik Static Configuration

The static configuration defines Traefik's core behavior—entrypoints, providers, certificate resolvers, and the API. Create traefik/traefik.yml:

# traefik/traefik.yml
# Static configuration: requires restart to apply changes

# Entrypoints define the ports Traefik listens on
entryPoints:
  web:
    address: ":80"
    # HTTP-to-HTTPS redirect will be applied via middleware
  websecure:
    address: ":443"
    http:
      tls: {}

# Providers tell Traefik where to discover configuration
providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: "traefik-public"
    watch: true
    swarmMode: false
  file:
    directory: "/etc/traefik/dynamic"
    watch: true

# API and dashboard (secure in production!)
api:
  dashboard: true
  debug: false

# Let's Encrypt certificate resolver
certificatesResolvers:
  letsencrypt:
    acme:
      email: "your-email@example.com"
      storage: "/etc/traefik/certs/acme.json"
      httpChallenge:
        entryPoint: "web"
      # For production, consider using TLS-ALPN-01 challenge
      # or a DNS challenge for internal/air-gapped services

# Global access log
log:
  level: "INFO"
  format: "json"

# Global metrics (optional)
metrics:
  prometheus:
    addEntryPointsLabels: true
    addServicesLabels: true

Docker Compose Core Stack

Here is the docker-compose.yml that ties everything together. This file defines the Traefik service and a shared network:

# docker-compose.yml
version: "3.9"

services:
  # --- Traefik Reverse Proxy ---
  traefik:
    image: traefik:v3.1
    container_name: traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      # Dashboard port (restrict in production via firewall or VPN)
      - "8080:8080"
    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/
    networks:
      - traefik-public
    labels:
      # Dashboard route with authentication
      traefik.enable: "true"
      traefik.http.routers.dashboard.rule: "Host(`traefik.example.com`)"
      traefik.http.routers.dashboard.service: "api@internal"
      traefik.http.routers.dashboard.entrypoints: "websecure"
      traefik.http.routers.dashboard.tls: "true"
      traefik.http.routers.dashboard.tls.certresolver: "letsencrypt"
      traefik.http.routers.dashboard.middlewares: "auth-basic"
      # Define basic auth for dashboard protection
      traefik.http.middlewares.auth-basic.basicauth.users: "admin:$$apr1$$abcdefgh$$somehashvalue"
      # Alternatively, use an environment variable:
      # traefik.http.middlewares.auth-basic.basicauth.usersfile: "/etc/traefik/usersfile"

networks:
  traefik-public:
    name: traefik-public
    driver: bridge
    attachable: true

Important: Generate the basic auth password hash using htpasswd -nb admin yourpassword or echo $(htpasswd -nb admin yourpassword) | sed -e 's/\$/\$\$/g' to escape dollar signs for Docker Compose.

Adding Your First Backend Service

Now let's add the classic whoami service—a simple HTTP server that returns container metadata. Add this to your docker-compose.yml:

  # --- Example Backend Service: whoami ---
  whoami:
    image: containous/whoami
    container_name: whoami
    restart: unless-stopped
    networks:
      - traefik-public
    labels:
      traefik.enable: "true"
      # Router definition
      traefik.http.routers.whoami.rule: "Host(`whoami.example.com`)"
      traefik.http.routers.whoami.entrypoints: "websecure"
      traefik.http.routers.whoami.tls: "true"
      traefik.http.routers.whoami.tls.certresolver: "letsencrypt"
      # Service definition (explicitly point to container port)
      traefik.http.services.whoami.loadbalancer.server.port: "80"
      # Optional: apply middleware
      traefik.http.routers.whoami.middlewares: "compression,security-headers"

Spin it up with:

docker compose up -d

Within seconds, Traefik detects the new container, provisions a Let's Encrypt certificate for whoami.example.com, and begins routing HTTPS traffic to it. You can verify with curl https://whoami.example.com or visit it in a browser.

Best Practices

1. Never Expose Containers by Default

Always set exposedByDefault: false in the Docker provider configuration. This prevents accidental exposure of containers that shouldn't be publicly accessible. Every service must explicitly opt-in with traefik.enable: "true".

providers:
  docker:
    exposedByDefault: false  # Critical security setting

2. Use a Dedicated Network for Traefik-Routed Traffic

Create a dedicated overlay or bridge network (e.g., traefik-public) that Traefik and all publicly routable services share. Internal services (databases, caches) should live on separate private networks. This enforces network-level isolation.

networks:
  traefik-public:
    name: traefik-public
    driver: bridge
  internal-db-net:
    name: internal-db-net
    driver: bridge
    internal: true  # Prevents external access entirely

3. Centralize Middleware Definitions

Define reusable middleware in a dedicated dynamic configuration file rather than repeating labels across every service. This keeps configurations DRY and ensures consistent behavior.

# traefik/dynamic/middlewares.yml
http:
  middlewares:
    compression:
      compress:
        excludedContentTypes:
          - "text/event-stream"
    
    security-headers:
      headers:
        browserXssFilter: true
        contentTypeNosniff: true
        referrerPolicy: "strict-origin-when-cross-origin"
        customFrameOptionsValue: "DENY"
        forceSTSHeader: true
        stsSeconds: 31536000
        stsIncludeSubdomains: true
        stsPreload: true
    
    rate-limit-general:
      rateLimit:
        average: 100
        burst: 50
    
    retry:
      retry:
        attempts: 3
        initialInterval: "100ms"
    
    redirect-www-to-nonwww:
      redirectRegex:
        regex: "^https?://www\\.(.+)"
        replacement: "https://${1}"
        permanent: true
    
    redirect-nonwww-to-www:
      redirectRegex:
        regex: "^https?://(?:www\\.)?(.+)"
        replacement: "https://www.${1}"
        permanent: true

Reference these in your service labels:

traefik.http.routers.myapp.middlewares: "compression,security-headers,rate-limit-general"

4. Secure the API and Dashboard

The Traefik dashboard is a powerful tool that shows all routes, services, and middleware. Never expose it without authentication on a public network. Three recommended approaches:

# IP whitelist example
traefik.http.middlewares.admin-whitelist.ipwhitelist.sourcerange: "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16"
traefik.http.routers.dashboard.middlewares: "admin-whitelist"

5. Implement Graceful TLS Handling

Always define a catch-all router for HTTPS that handles unmatched hostnames gracefully. This prevents certificate mismatch errors and improves user experience.

# Catch-all TLS router
traefik.http.routers.catchall-tls.rule: "HostRegexp(`{any:.*}`)"
traefik.http.routers.catchall-tls.entrypoints: "websecure"
traefik.http.routers.catchall-tls.tls: "true"
traefik.http.routers.catchall-tls.tls.certresolver: "letsencrypt"
traefik.http.routers.catchall-tls.priority: 1
traefik.http.routers.catchall-tls.middlewares: "catchall-fallback"
traefik.http.middlewares.catchall-fallback.errors.status: ["404"]
traefik.http.middlewares.catchall-fallback.errors.service: "fallback-service@internal"

6. Use Health Checks to Prevent Routing to Unhealthy Containers

Traefik respects Docker health checks. When a container's health check fails, Traefik removes it from the load balancing pool automatically.

  myservice:
    image: myapp:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    labels:
      traefik.http.services.myservice.loadbalancer.healthcheck.path: "/health"
      traefik.http.services.myservice.loadbalancer.healthcheck.interval: "30s"

7. Pin Image Versions and Use Specific Tags

Always pin Traefik to a specific version (e.g., traefik:v3.1.0) rather than using latest. Traefik major version upgrades can introduce breaking configuration changes.

  traefik:
    image: traefik:v3.1.0  # Pinned for reproducibility

8. Separate Static and Dynamic Configuration

Static configuration (entrypoints, providers, certificate resolvers) goes in traefik.yml and requires a Traefik restart. Dynamic configuration (routers, services, middleware) comes from Docker labels or file providers and applies instantly. Keep this separation clean—it makes debugging much easier.

9. Use Environment Variables for Secrets

Never hardcode API keys, passwords, or email addresses in configuration files. Use Docker Compose environment variables or Docker secrets in Swarm mode.

  traefik:
    environment:
      CF_API_EMAIL: ${CF_API_EMAIL}
      CF_API_KEY: ${CF_API_KEY}
      DASHBOARD_USERS: ${TRAEFIK_DASHBOARD_USERS}

10. Enable Access Logging and Monitoring

Structured access logs in JSON format integrate seamlessly with log aggregation tools like ELK, Loki, or Datadog. Enable Prometheus metrics for monitoring.

log:
  level: "INFO"
  format: "json"
  filePath: "/var/log/traefik/access.log"

metrics:
  prometheus:
    addEntryPointsLabels: true
    addServicesLabels: true

Common Pitfalls and How to Avoid Them

Pitfall 1: Traefik Cannot Reach Containers Due to Network Mismatch

The Problem: You define a service, add labels, but Traefik returns a 503 or "no healthy upstream" error. This is almost always a network issue. Traefik must be on the same network as the backend service, and the network must be declared in the provider configuration.

The Fix: Ensure both Traefik and the backend share the traefik-public network. Verify the network name in the Traefik Docker provider matches the network in your compose file:

# In traefik.yml
providers:
  docker:
    network: "traefik-public"  # Must match the network name exactly

# In docker-compose.yml
networks:
  traefik-public:
    name: traefik-public

Debugging tip: Exec into the Traefik container and ping the backend container by name: docker exec traefik ping myservice. If it fails, the networks aren't shared.

Pitfall 2: Port Exposure Conflicts

The Problem: You expose ports directly on backend services (ports: - "8080:8080") while also routing through Traefik. This creates two paths to the service—one through Traefik with TLS and middleware, and one raw on the host port—bypassing all security controls.

The Fix: Backend services that are exclusively routed through Traefik should never expose ports to the host. Remove the ports section entirely:

  # BAD - exposes port directly to host
  myservice:
    ports:
      - "8080:8080"
    networks:
      - traefik-public

  # GOOD - only accessible via Traefik
  myservice:
    networks:
      - traefik-public
    labels:
      traefik.http.services.myservice.loadbalancer.server.port: "8080"

Pitfall 3: Missing or Incorrect Service Port Label

The Problem: Traefik can discover the container but doesn't know which port to forward traffic to. Without the port label, Traefik defaults to the first exposed port or fails entirely.

The Fix: Always explicitly define the service port:

traefik.http.services.myservice.loadbalancer.server.port: "8080"

If your container exposes multiple ports, this label is mandatory. Use docker inspect yourcontainer to verify exposed ports.

Pitfall 4: Let's Encrypt Rate Limiting During Development

The Problem: You're iterating on your configuration, repeatedly restarting services, and suddenly certificate provisioning fails with "too many requests." Let's Encrypt imposes a hard limit of 50 certificates per registered domain per week.

The Fix: Use the Traefik staging environment during development:

certificatesResolvers:
  letsencrypt:
    acme:
      caServer: "https://acme-staging-v02.api.letsencrypt.org/directory"
      # Switch back to production by removing or commenting out caServer

Also, persist the acme.json file across restarts to avoid re-provisioning certificates unnecessarily. The file must have 600 permissions.

# Ensure acme.json exists with correct permissions before starting
touch traefik/certs/acme.json
chmod 600 traefik/certs/acme.json

Pitfall 5: Dollar Sign Escaping in Docker Compose Labels

The Problem: Docker Compose interprets $ as a variable substitution. When defining basic auth credentials or regex patterns with $ signs, the values get corrupted or cause parsing errors.

The Fix: Double the dollar signs or use the explicit inline syntax:

# BAD - $ gets interpreted by Docker Compose
traefik.http.middlewares.auth.basicauth.users: "admin:$apr1$salt$hash"

# GOOD - double dollar signs
traefik.http.middlewares.auth.basicauth.users: "admin:$$apr1$$salt$$hash"

# Alternatively, use a file reference
traefik.http.middlewares.auth.basicauth.usersfile: "/etc/traefik/users"

Pitfall 6: Router Priority Conflicts

The Problem: Multiple routers match the same hostname and path, causing unpredictable routing. Traefik uses rule length as the default priority heuristic, but this can lead to unexpected behavior with overlapping rules.

The Fix: Explicitly set priorities on routers when rules could overlap:

# Higher priority (evaluated first)
traefik.http.routers.api-v2.priority: 100
traefik.http.routers.api-v2.rule: "Host(`api.example.com`) && PathPrefix(`/v2`)"

# Lower priority (fallback)
traefik.http.routers.api-default.priority: 10
traefik.http.routers.api-default.rule: "Host(`api.example.com`)"

Pitfall 7: WebSocket Connections Dropped

The Problem: WebSocket connections through Traefik fail or disconnect intermittently. This happens when middleware or load balancer settings interfere with long-lived connections.

The Fix: For WebSocket services, ensure sticky sessions are enabled and timeouts are appropriate:

traefik.http.services.ws-service.loadbalancer.sticky: "true"
traefik.http.services.ws-service.loadbalancer.sticky.cookie.name: "traefik-sticky"
traefik.http.services.ws-service.loadbalancer.sticky.cookie.secure: "true"

Also, avoid middleware that buffers responses (like compression for streaming responses) on WebSocket routes.

Pitfall 8: Traefik Configuration File Syntax Errors Go Unnoticed

The Problem: A syntax error in traefik.yml causes Traefik to fail silently or use defaults without warning. YAML indentation errors or unknown keys are especially dangerous.

The Fix: Validate configuration before deploying:

docker run --rm -v $(pwd)/traefik/traefik.yml:/etc/traefik/traefik.yml \
  traefik:v3.1 --configFile=/etc/traefik/traefik.yml --validateOnly

This command exits with code 0 if valid, or prints detailed errors if not. Integrate it into CI/CD pipelines.

Pitfall 9: CORS Issues Due to Missing or Misconfigured Middleware

The Problem: Frontend applications receive CORS errors when calling APIs through Traefik. The browser's preflight requests are not handled correctly.

The Fix: Apply a CORS middleware to the API routes, not the frontend routes. Also handle OPTIONS preflight requests explicitly:

# traefik/dynamic/middlewares.yml
http:
  middlewares:
    cors-api:
      headers:
        accessControlAllowMethods:
          - "GET"
          - "POST"
          - "PUT"
          - "DELETE"
          - "OPTIONS"
        accessControlAllowHeaders:
          - "Content-Type"
          - "Authorization"
        accessControlAllowOriginList:
          - "https://frontend.example.com"
        accessControlMaxAge: 3600
        accessControlAllowCredentials: true

Apply this middleware to API routers only:

traefik.http.routers.api.middlewares: "cors-api,compression"

Pitfall 10: Container Restarts Cause Brief Downtime

The Problem: When a single container restarts, Traefik temporarily removes it from the pool and may return 503s for in-flight requests.

The Fix: Use circuit breakers and retry middleware to gracefully handle temporary outages:

# Circuit breaker
traefik.http.middlewares.cb-api.circuitbreaker.expression: "LatencyAtQuantileMS(50.0, 500) > 1000"

# Retry logic
traefik.http.middlewares.retry-api.retry.attempts: "3"
traefik.http.middlewares.retry-api.retry.initialinterval: "100ms"

For zero-downtime deployments, ensure at least two replicas are running during updates, and consider using Docker Swarm or Kubernetes for rolling updates.

Full Production-Grade docker-compose.yml Example

Here's a complete, production-ready example combining all the best practices above:

version: "3.9"

services:
  traefik:
    image: traefik:v3.1.0
    container_name: traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    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/
    networks:
      - traefik-public
    environment:
      ACME_EMAIL: "${ACME_EMAIL:-admin@example.com}"
    labels:
      traefik.enable: "true"
      traefik.http.routers.dashboard.rule: "Host(`traefik.example.com`)"
      traefik.http.routers.dashboard.service: "api@internal"
      traefik.http.routers.dashboard.entrypoints: "websecure"
      traefik.http.routers.dashboard.tls: "true"
      traefik.http.routers.dashboard.tls.certresolver: "letsencrypt"
      traefik.http.routers.dashboard.middlewares: "admin-whitelist,auth-basic"
      traefik.http.middlewares.auth-basic.basicauth.usersfile: "/etc/traefik/users"
      traefik.http.middlewares.admin-whitelist.ipwhitelist.sourcerange: "10.0.0.0/8,172.16.0.0/12"

  whoami:
    image: containous/whoami:latest
    container_name: whoami
    restart: unless-stopped
    networks:
      - traefik-public
    labels:
      traefik.enable: "true"
      traefik.http.routers.whoami.rule: "Host(`whoami.example.com`)"
      traefik.http.routers.whoami.entrypoints: "websecure"
      traefik.http.routers.whoami.tls: "true"
      traefik.http.routers.whoami.tls.certresolver: "letsencrypt"
      traefik.http.routers.whoami.middlewares: "compression,security-headers,rate-limit-general"
      traefik.http.services.whoami.loadbalancer.server.port: "80"
      traefik.http.services.whoami.loadbalancer.healthcheck.path: "/health"
      traefik.http.services.whoami.loadbalancer.healthcheck.interval: "30s"

  # Example: Redis (internal, NOT exposed via Traefik)
  redis:
    image: redis:7.2-alpine
    container_name: redis
    restart: unless-stopped
    networks:
      - internal-db-net
    # No traefik labels = not exposed
    volumes:
      - redis-data:/data

networks:
  traefik-public:
    name: traefik-public
    driver: bridge
    attachable: true
  internal-db-net:
    name: internal-db-net
    driver: bridge
    internal: true

volumes:
  redis-data:

Conclusion

Docker Compose with Traefik represents a paradigm shift in how we handle reverse proxying and service routing. By embedding routing configuration directly into service definitions via labels, you eliminate the traditional disconnect between infrastructure and application code. The key takeaways are: always disable exposedByDefault, use a dedicated shared network, centralize middleware definitions, secure the dashboard, pin versions, validate configurations before deployment, and use the Let's Encrypt staging environment during development. When these practices are followed, you gain a self-healing, auto-TLS, dynamically updating reverse proxy that requires virtually zero maintenance as your service topology evolves. The initial investment in understanding Traefik's label-based configuration model pays dividends in operational simplicity and reduced incident response time.

🚀 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