Overview
Every self-hosted stack eventually runs into the same problem: you have dozens of services on dozens of ports, no TLS, and a browser full of "your connection is not private" warnings. Traefik solves this elegantly. It acts as the single entry point for all HTTP/HTTPS traffic, automatically routes requests to the correct container based on Docker labels, and handles certificate provisioning and renewal via Let's Encrypt — entirely without manual configuration.
This project walks through deploying Traefik v3 in a Docker Compose stack. By the end you'll have:
- A single Traefik container handling all inbound traffic on ports 80 and 443
- Automatic HTTPS with Let's Encrypt certificates for every service
- HTTP → HTTPS redirect enforced globally
- Security headers middleware applied site-wide
- A password-protected Traefik dashboard
- Label-based routing so adding a new service takes three lines of YAML
Unlike nginx or HAProxy, Traefik requires zero manual reloads — it watches the Docker socket and reconfigures itself in real time when containers start or stop.
Architecture
Internet
│
│ :80 / :443
▼
┌──────────────────────────────────┐
│ Traefik v3 │
│ ┌──────────────────────────┐ │
│ │ EntryPoints │ │
│ │ web (80) → websecure │ │
│ │ websecure (443) → ACME │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ Providers │ │
│ │ Docker (label scrape) │ │
│ │ File (static middleware)│ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ Certificate Resolver │ │
│ │ ACME HTTP-01 / DNS-01 │ │
│ └──────────────────────────┘ │
└──────────────┬───────────────────┘
│
┌─────────┼──────────┐
▼ ▼ ▼
app:8080 api:3000 grafana:3000
Traefik reads Docker labels on each container to determine:
- Rule — which hostname routes to that container (
Host(\app.example.com`)`) - Service — which port to forward to
- Middleware — which transformations to apply (HTTPS redirect, headers, auth)
- TLS — which certificate resolver to use
All configuration lives in two places: traefik.yml (static config, requires restart) and Docker labels (dynamic config, hot-reloaded).
Prerequisites
- A Linux host running Docker Engine 24+ and Docker Compose v2
- A registered domain name with DNS pointed at your host's public IP
- Ports 80 and 443 open on your firewall/router
- An email address for Let's Encrypt registration
For a homelab behind NAT, configure port-forwarding on your router and use a Dynamic DNS service (Cloudflare, DuckDNS) if you don't have a static IP.
Step 1 — Directory Structure
Create the Traefik project directory:
mkdir -p ~/traefik/{config,certs,logs}
cd ~/traefikYou'll end up with:
traefik/
├── docker-compose.yml
├── config/
│ ├── traefik.yml # Static configuration
│ └── middleware.yml # Reusable middleware definitions
├── certs/
│ └── acme.json # Let's Encrypt certificate store
└── logs/
└── access.log
Step 2 — Static Configuration
Create config/traefik.yml. This is the only file that requires a Traefik restart to apply:
# config/traefik.yml
global:
checkNewVersion: true
sendAnonymousUsage: false
api:
dashboard: true
# insecure: false — dashboard is exposed via a secure router below
log:
level: INFO
filePath: /logs/traefik.log
accessLog:
filePath: /logs/access.log
bufferingSize: 100
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false # must opt-in with traefik.enable=true
network: proxy
file:
filename: /config/middleware.yml
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: you@example.com # replace with your email
storage: /certs/acme.json
httpChallenge:
entryPoint: webKey decisions:
exposedByDefault: false— Traefik ignores containers that don't havetraefik.enable=true. This prevents accidental exposure.- The
webentrypoint issues a permanent redirect towebsecure, enforcing HTTPS globally. httpChallengeworks for public-facing hosts. For homelab hosts that aren't reachable from the internet, switch todnsChallengeusing your DNS provider's API.
Step 3 — Middleware Definitions
Create config/middleware.yml for reusable middleware. These are referenced by Docker labels:
# config/middleware.yml
http:
middlewares:
# Strips www from requests
strip-www:
redirectRegex:
regex: "^https://www\\.(.*)"
replacement: "https://${1}"
permanent: true
# Security headers — applied to all services
secure-headers:
headers:
sslRedirect: true
forceSTSHeader: true
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "same-origin"
permissionsPolicy: "camera=(), microphone=(), geolocation=()"
customResponseHeaders:
X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex"
server: ""
# Rate limiting — 100 req/s average, 200 burst
rate-limit:
rateLimit:
average: 100
burst: 200
# Basic auth for dashboard (generate with: htpasswd -nB admin)
dashboard-auth:
basicAuth:
users:
- "admin:$2y$05$..." # replace with htpasswd outputGenerate the bcrypt password hash:
# Install apache2-utils if needed
sudo apt install apache2-utils -y
# Generate hash — you'll be prompted for a password
htpasswd -nB admin
# Output: admin:$2y$05$abc123...Paste the full user:hash string into dashboard-auth.users.
Step 4 — Certificate Storage
Create the acme.json file and restrict its permissions. Let's Encrypt will refuse to write if this file is world-readable:
touch ~/traefik/certs/acme.json
chmod 600 ~/traefik/certs/acme.jsonStep 5 — Docker Compose
Create docker-compose.yml:
networks:
proxy:
external: true
services:
traefik:
image: traefik:v3.3
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy
ports:
- "80:80"
- "443:443"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config/traefik.yml:/config/traefik.yml:ro
- ./config/middleware.yml:/config/middleware.yml:ro
- ./certs/acme.json:/certs/acme.json
- ./logs:/logs
labels:
- "traefik.enable=true"
# Dashboard router
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=dashboard-auth@file,secure-headers@file"Create the external proxy network before starting:
docker network create proxyStart Traefik:
docker compose up -d
docker compose logs -f traefikWatch the logs for msg="Starting provider" and msg="Configuration loaded". After 30–60 seconds you should see certificate acquisition messages if DNS is resolving correctly.
Step 6 — Add Your First Service
Any container on the proxy network with the right labels is automatically discovered. Here's a simple example using Whoami (a request inspector):
# whoami/docker-compose.yml
networks:
proxy:
external: true
services:
whoami:
image: traefik/whoami:latest
container_name: whoami
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
- "traefik.http.routers.whoami.middlewares=secure-headers@file,rate-limit@file"
- "traefik.http.services.whoami.loadbalancer.server.port=80"cd whoami && docker compose up -dTraefik detects the new container immediately — no restart required. Visit https://whoami.example.com and you'll see the request headers including X-Forwarded-For and X-Real-Ip injected by Traefik.
For a service that listens on a non-standard port, set:
- "traefik.http.services.myapp.loadbalancer.server.port=8080"Step 7 — Real-World Service Example (Portainer)
networks:
proxy:
external: true
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- portainer_data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.portainer.rule=Host(`portainer.example.com`)"
- "traefik.http.routers.portainer.entrypoints=websecure"
- "traefik.http.routers.portainer.tls.certresolver=letsencrypt"
- "traefik.http.services.portainer.loadbalancer.server.port=9000"
- "traefik.http.routers.portainer.middlewares=secure-headers@file"
volumes:
portainer_data:Step 8 — DNS Challenge for Internal Services (Optional)
If you want TLS certificates for services that are not publicly reachable (e.g., internal homelab hosts behind a LAN), switch to DNS-01 challenge. Example for Cloudflare:
# In traefik.yml — add alongside or replace httpChallenge
certificatesResolvers:
letsencrypt:
acme:
email: you@example.com
storage: /certs/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"Add the Cloudflare API token to your environment or a .env file:
CF_DNS_API_TOKEN=your_cloudflare_api_tokenMount it into the Traefik container:
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}Traefik supports 30+ DNS providers (Route53, DigitalOcean, Namecheap, etc.) via lego.
Testing
Verify TLS Certificate
# Check certificate issuer and expiry
openssl s_client -connect whoami.example.com:443 -servername whoami.example.com \
</dev/null 2>/dev/null | openssl x509 -noout -issuer -datesExpected output:
issuer=C=US, O=Let's Encrypt, CN=R11
notBefore=Apr 29 00:00:00 2026 GMT
notAfter=Jul 28 00:00:00 2026 GMT
Verify HTTP → HTTPS Redirect
curl -I http://whoami.example.com
# HTTP/1.1 301 Moved Permanently
# Location: https://whoami.example.com/Verify Security Headers
curl -sI https://whoami.example.com | grep -E "Strict|X-Frame|X-Content|Referrer"
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# Referrer-Policy: same-originSSL Labs Score
Run your domain through SSL Labs — with the default Traefik v3 TLS settings and the security headers middleware above, you should score A or A+.
Check Traefik Dashboard
Visit https://traefik.example.com (using the credentials from Step 3) to see:
- All discovered routers and services
- Certificate status and expiry
- Middleware assignments
- Health of each backend
Monitoring & Observability
Prometheus Metrics
Enable the built-in metrics endpoint in traefik.yml:
metrics:
prometheus:
addEntryPointsLabels: true
addServicesLabels: true
addRoutersLabels: true
entryPoint: metrics
entryPoints:
metrics:
address: ":8082"Add the metrics entrypoint port to the Docker Compose ports section (8082:8082) and configure a Prometheus scrape job:
# prometheus.yml
scrape_configs:
- job_name: traefik
static_configs:
- targets: ["traefik:8082"]Import the official Traefik Grafana dashboard (ID 17346) for instant visibility into request rates, error rates, and certificate status.
Log Rotation
Add logrotate config to prevent logs from filling disk:
sudo tee /etc/logrotate.d/traefik > /dev/null <<'EOF'
/home/user/traefik/logs/*.log {
daily
rotate 14
compress
missingok
notifempty
sharedscripts
postrotate
docker kill --signal="USR1" traefik
endscript
}
EOFTroubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Certificate not issued | Port 80 blocked or DNS not resolving | Check firewall and DNS propagation with dig +short yourdomain.com |
acme.json permission error | File is world-readable | chmod 600 certs/acme.json |
| Service not appearing in dashboard | Container not on proxy network | Add networks: [proxy] to service |
| 404 from Traefik | Router rule mismatch | Double-check Host() label matches the request hostname exactly |
| Let's Encrypt rate limit | Too many certificate requests | Use staging resolver (https://acme-staging-v02.api.letsencrypt.org/directory) during testing |
Switch to the Let's Encrypt staging environment during development to avoid hitting rate limits (5 certificates per domain per week):
certificatesResolvers:
staging:
acme:
caServer: "https://acme-staging-v02.api.letsencrypt.org/directory"
email: you@example.com
storage: /certs/acme-staging.json
httpChallenge:
entryPoint: webExtensions & Next Steps
IP Allowlisting for Admin Services
Restrict access to internal tools by source IP:
# In middleware.yml
http:
middlewares:
lan-only:
ipAllowList:
sourceRange:
- "192.168.1.0/24"
- "10.0.0.0/8"Apply it to internal services:
- "traefik.http.routers.portainer.middlewares=lan-only@file,secure-headers@file"Forward Authentication with Authentik or Authelia
Replace per-service basic auth with SSO by placing an auth middleware in front of every service:
# middleware.yml
http:
middlewares:
authentik:
forwardAuth:
address: "http://authentik:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-groupsAutomatic Container Labeling with Labels Templates
For teams with many similar services, use a .env file with a shared label template and Docker Compose's extension fields (x-traefik-labels) to DRY up repetitive label blocks.
High Availability
Run two Traefik instances with a shared Redis backend for certificate storage and state:
certificatesResolvers:
letsencrypt:
acme:
storage: redis://redis:6379This allows zero-downtime Traefik updates and active-active load balancing via keepalived or your cloud provider's LB.
Summary
You now have a production-grade reverse proxy that:
- Terminates TLS for every service with auto-renewing Let's Encrypt certificates
- Enforces HTTPS, security headers, and rate limiting globally
- Auto-discovers new containers the moment they start — zero manual config
- Exposes Prometheus metrics ready for Grafana dashboards
The pattern scales from a single Raspberry Pi running three services to a multi-node Docker Swarm cluster with hundreds of containers. Once Traefik is in place, adding a new service is three labels and a docker compose up.