Traefik is a reverse proxy that integrates natively with Docker and Kubernetes. Add labels to your Docker containers and Traefik automatically detects them, configures routing, and issues SSL certificates via Let’s Encrypt — no nginx config files to write per service. For remote teams managing multiple services, it dramatically reduces the operational surface area.
Unlike nginx, which requires you to write a new server block and reload config for every service you deploy, Traefik watches your Docker socket in real time. Deploy a new container with the right labels and traffic starts routing within seconds. Remove the container and routing disappears. This dynamic behavior is the core reason Traefik has become the default choice for teams running microservices or self-hosted tooling on a single host.
Deploy Traefik with Docker Compose
# docker-compose.yml
version: "3.8"
services:
traefik:
image: traefik:v3.0
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "80:80"
- "443:443"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/traefik.yml:/traefik.yml:ro
- ./traefik/config:/config:ro
- traefik-certs:/certs
networks:
- proxy
labels:
- "traefik.enable=true"
# Dashboard
- "traefik.http.routers.traefik.rule=Host(`traefik.yourcompany.com`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=auth"
- "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$..."
networks:
proxy:
external: true
volumes:
traefik-certs:
Create the Docker network first:
docker network create proxy
The security_opt: no-new-privileges:true line is important. It prevents the Traefik process from gaining additional privileges via setuid/setgid binaries — a defense-in-depth measure since Traefik mounts the Docker socket, which is a high-privilege resource. The Docker socket mount itself (/var/run/docker.sock:ro) is read-only to minimize the blast radius of any vulnerability in Traefik’s Docker provider.
Traefik Static Configuration
# traefik/traefik.yml
global:
checkNewVersion: false
sendAnonymousUsage: false
api:
dashboard: true
debug: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
certificatesResolvers:
letsencrypt:
acme:
email: ops@yourcompany.com
storage: /certs/acme.json
# Use tlsChallenge for simple setups
tlsChallenge: {}
# Or httpChallenge:
# httpChallenge:
# entryPoint: web
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: proxy
file:
directory: /config
watch: true
log:
level: INFO
format: json
accessLog:
format: json
fields:
defaultMode: keep
headers:
defaultMode: drop
names:
User-Agent: keep
X-Forwarded-For: keep
The exposedByDefault: false setting is critical for security. Without it, every Docker container is automatically exposed through Traefik as soon as it connects to the proxy network. With it set to false, only containers with the explicit label traefik.enable=true get routed. This prevents accidentally exposing internal databases, caches, or background workers that happen to share the network.
The file provider with watch: true means Traefik hot-reloads any YAML files you drop in /config without a restart. This is where you put routing rules for non-Docker services.
Expose a Service with Labels
Any Docker container can be exposed through Traefik with labels:
# your-app/docker-compose.yml
version: "3.8"
services:
app:
image: yourcompany/app:latest
restart: unless-stopped
networks:
- proxy
- internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.yourcompany.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.services.app.loadbalancer.server.port=3000"
environment:
- DATABASE_URL=postgresql://...
networks:
proxy:
external: true
internal:
That’s all that’s needed. Traefik detects the container, acquires an SSL cert, and starts routing app.yourcompany.com to the container — no nginx config, no cert management.
The dual-network pattern (proxy + internal) is intentional. The proxy network is the one Traefik watches. The internal network is for communication between your app container and its database or cache. Your database never touches the proxy network and therefore is never exposed to Traefik routing or the outside world.
Dynamic Configuration for Non-Docker Services
For services not running in Docker (external APIs, bare-metal services), use file-based config:
# traefik/config/external-services.yml
http:
routers:
legacy-api:
rule: "Host(`api.yourcompany.com`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: legacy-api-service
middlewares:
- rate-limit
- secure-headers
internal-dashboard:
rule: "Host(`metrics.yourcompany.com`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: grafana
middlewares:
- auth
services:
legacy-api-service:
loadBalancer:
servers:
- url: "http://10.0.1.50:8080"
healthCheck:
path: /health
interval: 30s
timeout: 5s
grafana:
loadBalancer:
servers:
- url: "http://10.0.1.51:3000"
middlewares:
secure-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customFrameOptionsValue: "SAMEORIGIN"
rate-limit:
rateLimit:
average: 100
burst: 50
auth:
basicAuth:
usersFile: /config/htpasswd
The healthCheck on the legacy-api-service tells Traefik to probe /health every 30 seconds. If the probe fails, Traefik stops routing to that server and waits for it to recover before resuming. This prevents Traefik from sending traffic to a backend that is up at the TCP layer but not serving correctly.
Wildcard Certificates with DNS Challenge
For *.yourcompany.com wildcard certs, use a DNS challenge:
# traefik/traefik.yml additions
certificatesResolvers:
letsencrypt-wildcard:
acme:
email: ops@yourcompany.com
storage: /certs/acme-wildcard.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
Set environment variables for the Traefik container:
# In docker-compose.yml environment section
environment:
- CF_DNS_API_TOKEN=${CLOUDFLARE_DNS_TOKEN}
Use the wildcard cert in a router:
labels:
- "traefik.http.routers.app.tls.domains[0].main=yourcompany.com"
- "traefik.http.routers.app.tls.domains[0].sans=*.yourcompany.com"
- "traefik.http.routers.app.tls.certresolver=letsencrypt-wildcard"
Wildcard certs are particularly useful for internal tooling where you’re adding new subdomains frequently. One cert covers all of them. The tradeoff is that the Cloudflare API token you provide needs DNS edit access — store it with care. Create a scoped API token in Cloudflare that has Zone:DNS:Edit access only to the relevant zone, rather than using your account-level API key.
Load Balancing Multiple Replicas
Traefik automatically load balances when multiple containers with the same labels are running:
# Scale to 3 replicas — Traefik detects and round-robins automatically
docker-compose up --scale app=3 -d
For sticky sessions (session affinity):
labels:
- "traefik.http.services.app.loadbalancer.sticky.cookie=true"
- "traefik.http.services.app.loadbalancer.sticky.cookie.name=lb_session"
- "traefik.http.services.app.loadbalancer.sticky.cookie.secure=true"
Sticky sessions pin a user to the same backend container for the duration of their session. This matters for applications that store session state in memory rather than a shared store. The cleaner solution is moving session state to Redis and removing the need for stickiness, but sticky sessions are a valid interim step.
IP Allowlisting for Internal Services
Restrict access to internal dashboards by IP:
# traefik/config/middlewares.yml
http:
middlewares:
office-only:
ipAllowList:
sourceRange:
- "10.0.0.0/8" # Internal network
- "203.0.113.0/24" # Office IP range
- "198.51.100.42/32" # VPN exit IP
Apply to a router:
labels:
- "traefik.http.routers.internal-app.middlewares=office-only"
This pattern works well for locking down monitoring dashboards (Grafana, Prometheus, Alertmanager) and CI interfaces that should never be accessible from arbitrary internet addresses. Combine it with basic auth for defense in depth — IP allowlisting can be bypassed if someone is on a shared network or VPN.
For remote teams where developers connect from varying IP addresses, the VPN exit IP approach is common: all VPN traffic exits from a known static IP, and only that IP (plus any office ranges) is allowed.
Health Checks and Circuit Breakers
Traefik supports health checks on backend services and can remove unhealthy servers from rotation automatically:
# traefik/config/services.yml
http:
services:
app-service:
loadBalancer:
healthCheck:
path: /healthz
interval: 10s
timeout: 3s
# Remove server if it fails 3 consecutive checks
servers:
- url: "http://10.0.1.10:3000"
- url: "http://10.0.1.11:3000"
For circuit breaking — stopping traffic to a service when its error rate spikes:
http:
middlewares:
circuit-breaker:
circuitBreaker:
expression: "ResponseCodeRatio(500, 600, 0, 600) > 0.30 || NetworkErrorRatio() > 0.10"
This opens the circuit breaker when more than 30% of responses are 5xx errors or more than 10% of connections fail at the network level. During the open state, Traefik returns a 503 rather than forwarding requests to an overloaded backend.
Monitor with Traefik Access Logs
Parse access logs for slow requests:
# Find requests over 1 second
docker logs traefik 2>&1 \
| jq 'select(.Duration > 1000000000)' \
| jq '{url: .RequestPath, duration_ms: (.Duration/1000000), status: .DownstreamStatus}'
Check cert expiry dates:
curl -s "http://localhost:8080/api/overview" \
| jq '.http.routers | to_entries[] | {name: .key, tls: .value.tls}'
List all active routers and their status via the API:
# View all HTTP routers
curl -s http://localhost:8080/api/http/routers | jq '.[].name'
# Check a specific router's configuration
curl -s http://localhost:8080/api/http/routers/app@docker | jq .
# See all services and their health
curl -s http://localhost:8080/api/http/services | jq '.[] | {name: .name, servers: .loadBalancer.servers}'
The Traefik dashboard at port 8080 (or behind your configured router) gives you a live view of all routers, services, and middleware — which containers are connected, which certs are active, and which routes are healthy. For remote teams where infrastructure changes happen asynchronously, the dashboard is the quickest way to verify a new service came up correctly without SSHing into the host.
Middleware Chains for Production Hardening
Combining multiple middlewares into a chain gives you layered defense — rate limiting, secure headers, and auth in one pass. Define the chain in your dynamic config, then apply the chain name to any router.
# traefik/config/middleware-chains.yml
http:
middlewares:
# Individual middlewares
rate-limit-api:
rateLimit:
average: 60
burst: 20
period: 1m
sourceCriterion:
ipStrategy:
depth: 1
compress:
compress:
excludedContentTypes:
- text/event-stream
secure-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
frameDeny: true
forceSTSHeader: true
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "camera=(), microphone=(), geolocation=()"
customResponseHeaders:
X-Robots-Tag: "noindex, nofollow" # for internal services
# Chain them together
api-chain:
chain:
middlewares:
- rate-limit-api
- secure-headers
- compress
internal-chain:
chain:
middlewares:
- office-only
- secure-headers
Apply a chain to a container with one label:
labels:
- "traefik.http.routers.api.middlewares=api-chain@file"
The @file suffix tells Traefik the middleware is defined in a file provider, not Docker labels. Chains are reusable — define once, apply to any router.
Observability: Metrics and Tracing
Traefik exposes Prometheus metrics natively. Add the metrics endpoint to traefik.yml:
# traefik/traefik.yml additions
metrics:
prometheus:
addEntryPointsLabels: true
addRoutersLabels: true
addServicesLabels: true
buckets:
- 0.1
- 0.3
- 1.2
- 5.0
entryPoint: metrics
entryPoints:
metrics:
address: ":8082"
Scrape from Prometheus with:
# prometheus.yml
scrape_configs:
- job_name: traefik
static_configs:
- targets: ["traefik:8082"]
metrics_path: /metrics
Key metrics to alert on:
| Metric | What to watch |
|---|---|
traefik_entrypoint_requests_total |
Request volume by status code |
traefik_entrypoint_request_duration_seconds |
p99 latency per entrypoint |
traefik_service_open_connections |
Connection saturation |
traefik_router_requests_total{code="502"} |
Upstream failures |
For distributed tracing, add OpenTelemetry export (Traefik v3+):
tracing:
otlp:
grpc:
endpoint: tempo.internal:4317
insecure: true
This sends trace spans to Grafana Tempo or any OTLP-compatible backend, correlating Traefik routing decisions with downstream service spans.
Troubleshooting Common Traefik Issues
Certificate not renewing: Check docker logs traefik for ACME errors. Common causes: the domain doesn’t resolve to this server (Let’s Encrypt can’t complete the challenge), or acme.json has wrong permissions (chmod 600 acme.json). For DNS challenge failures, confirm the API token has zone edit permissions.
Service returns 502 Bad Gateway: Traefik reached the container but the container rejected the connection. Verify the loadbalancer.server.port label matches the actual port your app listens on. Check docker inspect <container> to confirm the container is on the proxy network.
Redirect loop on HTTPS: If the upstream service also redirects HTTP→HTTPS, and Traefik forwards to it via HTTP internally, you get a loop. Fix: ensure the upstream app trusts X-Forwarded-Proto and only redirects when it’s missing, or connect Traefik to the service via HTTPS with --serversTransport.insecureSkipVerify=true (dev only).
Dashboard not loading: The API router requires the api@internal service and must be on the websecure entrypoint. Confirm api.dashboard: true is in traefik.yml and your router labels include traefik.http.routers.traefik.service=api@internal.
New container not discovered: Ensure the container is on the proxy network (not just bridge) and has traefik.enable=true. Run docker network inspect proxy to confirm the container appears. If you added the container after Traefik started, Traefik should detect it automatically within seconds — check logs for "Skipping provider" messages.
# Inspect what Traefik currently sees
curl http://localhost:8080/api/rawdata | jq '.routers | keys'
# Watch for configuration events in real time
docker logs -f traefik 2>&1 | grep -E "(error|warn|router|service)"
Related Reading
- How to Set Up Portainer for Docker Management
- How to Automate SSL Certificate Renewal
- How to Set Up Netdata for Server Monitoring
- How to Set Freelance Developer Rates in 2026