If your Docker homelab has more than three web services, you need a reverse proxy. Without one, every container exposes its own port, you manage certificates by hand (or skip HTTPS entirely), and changing a service’s URL means editing Nginx configs and reloading.
Traefik solves all of this. It watches the Docker socket, discovers new containers automatically, provisions Let’s Encrypt certificates for any hostname you define via Docker labels, and handles middleware (auth, rate limiting, headers) without touching a static config file.
This post deploys Traefik v3 on Docker with:
- Automatic container discovery via Docker labels
- Let’s Encrypt (HTTP-01 and TLS-ALPN-01 challenges)
- HTTP→HTTPS redirect with permanent redirect middleware
- Rate limiting and IP whitelist examples
- Dashboard secured with HTTP Basic Auth
- Static and dynamic configuration split
Architecture
Internet
│
┌──────▼──────┐
│ Cloudflare │ (or any DNS provider)
│ ───────── │
│ *.lab.io │ → your public IP
└──────┬──────┘
│ port 80, 443
┌──────▼──────┐
│ Traefik │
│ ───────── │
│ EntryPoints│
│ :80 :443 │
└──┬───────┬──┘
│ │
┌──────────────┼───────┼──────────────────┐
│ │ │ │
│ Docker network: traefik │
│ │
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
│ │ Service A│ │ Service B│ │ Service C│ │
│ │ web:80 │ │ web:3000│ │ web:8080 │ │
│ └─────────┘ └─────────┘ └──────────┘ │
└────────────────────────────────────────────┘
Traefik binds to ports 80 and 443 on the host. All other web services bind to an internal Docker network and are exposed only through Traefik routes defined by Docker labels. No port conflicts, no manual cert renewal, no config reloads.
Directory Layout
/opt/docker/traefik/
├── compose.yml
├── .env
├── traefik.yml # Static config
├── config/
│ └── dynamic.yml # Dynamic config (middleware, etc.)
└── acme.json # Let's Encrypt cert storage (auto-created)
The split between traefik.yml and config/ is deliberate:
- Static config (
traefik.yml) — entrypoints, providers, cert resolvers, logging. Needs a restart to reload. - Dynamic config (
config/dynamic.yml) — middleware definitions, error pages, catch-all routers. Watched and reloaded live by Traefik.
1. Static Configuration
|
|
Key points:
exposedByDefault: false— containers must explicitly opt in with labels. Prevents accidental exposure of services.network: traefik— Traefik only discovers containers connected to thetraefiknetwork.- Two cert resolvers —
letsencryptfor production,letsencrypt-stagingfor testing (avoids rate limit hits during testing). - HTTP→HTTPS redirect — defined at the
webentrypoint, permanent (301). No middleware needed for the basic case.
The acme.json file stores certificates. When Traefik starts, it creates
this file with mode 0600 (required). If it doesn’t exist, create it:
|
|
2. Dynamic Configuration (Middlewares)
|
|
Split into reusable middleware building blocks. Services declare which middleware they want with labels — no monolithic config.
Generate the password hash for basic auth:
|
|
Take the hash output and put it in your .env:
|
|
3. Docker Compose
|
|
Security notes on cap_add:
NET_BIND_SERVICEis the minimum capability needed for Traefik to bind ports < 1024 (80 and 443).cap_drop: ALLremoves all capabilities, then we add back only what’s needed.- The Docker socket is mounted
:ro(read-only). Traefik only reads container metadata — it never writes to the socket.
4. Exposing Services with Docker Labels
This is where Traefik shines. Any container connected to the traefik
network with the right labels is automatically discovered, routed, and
TLS-protected.
Example: Whoogle Search (self-hosted search)
|
|
Only three label groups matter:
| Label | Purpose |
|---|---|
traefik.enable=true |
Opt-in (required because exposedByDefault: false) |
traefik.http.routers.<name>.rule=Host(\host.domain`)` |
Routing rule |
traefik.http.routers.<name>.tls.certresolver=letsencrypt |
Auto-get cert |
traefik.http.services.<name>.loadbalancer.server.port=<port> |
Container port |
When you don’t need the last label: If your container exposes port
80 (or only one port), Traefik auto-detects it. Use the explicit
.server.port label when your service uses a non-standard port.
Example: Grafana with middleware
|
|
Example: Internal-only service (Vaultwarden)
|
|
The internalOnly middleware blocks all traffic not originating from
RFC1918 IPs. If you want to access it externally, use a VPN instead
of exposing it to the internet.
5. DNS Challenge with Cloudflare
The default HTTP-01 challenge works when Traefik can reach Let’s Encrypt servers on port 80, which it does by default. But if:
- You want wildcard certificates (
*.lab.io) - Port 80 is blocked by your ISP
- Your service is behind a NAT where LE can’t reach you
Swap to DNS-01 challenge with Cloudflare:
|
|
And in .env:
|
|
Create a Cloudflare API Token with permissions:
- Zone → DNS → Edit
- Zone → Zone → Read
Then with dnsChallenge, Traefik can issue wildcard certs — every
*.lab.io subdomain is covered by one cert.
6. Deploying
|
|
7. Testing Certificates with Staging
Before hitting production Let’s Encrypt rate limits (50 certs/week per registered domain), test with the staging resolver:
|
|
Staging certificates show as untrusted in the browser but confirm your
challenge workflow works. Once it works, switch to letsencrypt and
Traefik will replace the cert automatically.
To clear a staging cert:
|
|
8. Troubleshooting
“404 page not found” on a new service
- Is the container connected to the
traefiknetwork? - Does it have
traefik.enable=true? - Does the hostname in
rule=Host(\…`)` resolve to this host? - Check
docker compose logs traefikfor router registration logs.
Certificate doesn’t renew
- Check ACME logs:
docker compose logs traefik | grep acme - If using HTTP-01, verify port 80 is reachable from the internet
- If using DNS-01, verify the API token has DNS:Edit permission
“too many routes” warning in logs
- Every container with
traefik.enable=truecreates at least one router. KeepexposedByDefault: falseand only enable what you need. - Old containers (stopped or removed) can leave orphaned routes until Traefik refreshes from Docker (every few seconds).
Dashboard shows “Internal Server Error”
- Check basic auth credentials — the hash format must match what
openssl passwd -apr1generates. - Traefik’s own labels must be correct since it discovers routes via the same Docker provider as everything else.
Port 80/443 already in use
- Traefik must be the only service listening on 80/443. If you have another web server or Nginx, either stop it or change Traefik’s bind ports in the compose file and add port mapping at the router level.
Why Traefik Over Nginx/Caddy for a Homelab
| Feature | Traefik | Nginx Proxy Manager | Caddy |
|---|---|---|---|
| Auto container discovery | ✅ Native via Docker socket | ❌ Manual per-service | ❌ Manual (or plugin) |
| Auto TLS (LE) | ✅ Built-in | ✅ Built-in | ✅ Built-in |
| Dynamic config (no reload) | ✅ Live reload | ✅ UI-based | ⚠️ File-based |
| Middleware (rate limit, auth) | ✅ Declarative (labels) | ✅ UI-based | ⚠️ Limited |
| Resource usage (idle) | ~40-60 MB RAM | ~60-80 MB RAM | ~15-25 MB RAM |
| Wildcard certs | ✅ DNS-01 | ✅ DNS-01 | ✅ DNS-01 |
| Kubernetes-style labels | ✅ Same syntax | ❌ | ❌ |
I used Nginx Proxy Manager for over a year. It works, but the UI becomes tedious once you have more than 10 services. Traefik’s Docker-native discovery means you add labels when you write the compose file — one step, not two. And if you ever move to Kubernetes (or Nomad), Traefik runs there with the exact same config model.
Resource Usage
| Component | RAM | Disk |
|---|---|---|
| Traefik (idle, 10 routes) | ~45 MB | ~50 MB (logs, certs) |
| Cert storage (10 domains) | — | ~50 KB per cert |
| Access logs (rotated daily) | — | ~10 MB/day (typical lab) |
Negligible. Traefik is written in Go and runs with zero overhead until traffic hits it.
Summary
|
|
A reverse proxy is the backbone of a self-hosted Docker environment. Traefik makes it automatic — you declare routes as container labels instead of maintaining a config file, TLS is zero-config with Let’s Encrypt, and middleware chains handle security, rate limiting, and access control without touching a config file.
If you’re still mapping port 8080 → container A, port 8081 → container B, and juggling self-signed certs, spend 20 minutes setting this up. It’s the last reverse proxy config you’ll ever write.