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:
- Automatic service discovery — Traefik watches Docker events and updates routes dynamically
- Automatic TLS with Let's Encrypt — Certificates are provisioned, renewed, and served without any manual intervention
- Middleware ecosystem — Rate limiting, redirects, authentication, retry logic, circuit breakers, and more are applied via simple labels
- Unified configuration — All routing rules live alongside your service definitions in docker-compose.yml
- API and dashboard — Visual overview of all routes, services, and middleware with a clean web UI
- Multi-protocol support — HTTP, HTTPS, TCP, and UDP traffic can all be proxied
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:
- Basic auth middleware (shown above) — simplest for internal tools
- IP whitelist middleware — restrict to VPN or office IP ranges
- Separate internal entrypoint — bind dashboard to a different port that's only accessible via internal network
# 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.