The Label Syntax Finally Makes Sense
You’ve picked Traefik. Good call. Now you’re staring at a docker-compose.yml with forty lines of traefik.* labels and wondering if someone just mashed their keyboard and called it a config file.
Here’s the thing: Traefik’s label syntax looks like chaos until you understand the three-layer mental model underneath it. Once that clicks, every label you write will make sense on the first try — or at least the second.
The Mental Model: Three Layers
Traefik has three concepts that chain together like pipes:
Entrypoints are where traffic enters — TCP ports. You define them once in static config. web is port 80, websecure is 443. That’s it.
Routers listen on entrypoints and decide where to send traffic based on rules. A Host() rule matches the domain. A PathPrefix() rule matches the URL path. Routers are defined per-service via labels.
Services are the backends — your actual containers. Traefik auto-discovers them from Docker and load-balances across instances.
The chain: port → router rule → service backend. Labels configure routers and services. Static config handles entrypoints.
The Minimal Working Setup
Two files. That’s all you need to start.
version: "3.8"
services: traefik: image: traefik:v3.2 container_name: traefik ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./traefik.yml:/traefik.yml:ro - ./acme.json:/acme.json networks: - proxy
whoami: image: traefik/whoami labels: - "traefik.enable=true" - "traefik.http.routers.whoami.rule=Host(`whoami.yourdomain.com`)" - "traefik.http.routers.whoami.entrypoints=websecure" - "traefik.http.routers.whoami.tls.certresolver=letsencrypt" networks: - proxy
networks: proxy: external: trueentryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https
websecure: address: ":443"
providers: docker: exposedByDefault: false network: proxy
certificatesResolvers: letsencrypt: acme: email: you@yourdomain.com storage: /acme.json httpChallenge: entryPoint: web
api: dashboard: true insecure: falseBefore you start, create the external network and the acme.json file:
docker network create proxytouch acme.json && chmod 600 acme.jsonThat chmod 600 is not optional. Traefik refuses to use acme.json if it’s world-readable.
Static Config vs Dynamic Config
This is where people get confused. There are two kinds of config in Traefik:
Static config (traefik.yml) — loads once at startup. Entrypoints, providers, certificate resolvers, the dashboard setting. Change this, restart Traefik.
Dynamic config (Docker labels) — Traefik watches in real time. Add a container with labels, Traefik picks it up instantly. No restart. This is the magic.
The rule: anything that requires a restart to change goes in traefik.yml. Everything per-service goes in labels.
TLS: HTTP Challenge vs DNS Challenge
The HTTP challenge (shown above) works by Let’s Encrypt making an HTTP request to yourdomain.com/.well-known/acme-challenge/. This means port 80 must be publicly reachable. Fine for most setups.
The DNS challenge is better when:
- You’re running internal services with private domains
- Port 80 is blocked at your router
- You want wildcard certs (
*.yourdomain.com)
For Cloudflare DNS challenge:
certificatesResolvers: cloudflare: acme: email: you@yourdomain.com storage: /acme.json dnsChallenge: provider: cloudflare resolvers: - "1.1.1.1:53" - "8.8.8.8:53"Set your Cloudflare API token as an environment variable in the Traefik container:
traefik: environment: - CF_DNS_API_TOKEN=your_cloudflare_token_hereStaging vs prod: Always test with Let’s Encrypt staging first. You get rate-limited fast if you hammer the prod endpoint with bad configs. Add caServer: https://acme-staging-v02.api.letsencrypt.org/directory to your certresolver, verify it works, then remove that line and delete acme.json to get a real cert.
Router Rules: Host, PathPrefix, Headers
Router rules are Go expressions. The common ones:
# Match a domain- "traefik.http.routers.app.rule=Host(`app.yourdomain.com`)"
# Match domain + path prefix- "traefik.http.routers.app.rule=Host(`yourdomain.com`) && PathPrefix(`/api`)"
# Match multiple domains- "traefik.http.routers.app.rule=Host(`app.com`) || Host(`www.app.com`)"
# Match on a header (useful for API versioning)- "traefik.http.routers.app.rule=Host(`app.com`) && Headers(`X-Version`, `v2`)"Middlewares: Where the Real Power Lives
Middlewares transform requests before they hit your service. You define them with labels and then attach them to routers.
HTTPS redirect (if you want it per-router instead of globally):
- "traefik.http.middlewares.redirect-https.redirectscheme.scheme=https"- "traefik.http.middlewares.redirect-https.redirectscheme.permanent=true"- "traefik.http.routers.app-http.middlewares=redirect-https"Basic auth (generate the hash with htpasswd -nb user password):
- "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz...hashed..."- "traefik.http.routers.app.middlewares=auth"Dollar signs in label values need to be escaped as $$ in Compose files.
Rate limiting:
- "traefik.http.middlewares.ratelimit.ratelimit.average=100"- "traefik.http.middlewares.ratelimit.ratelimit.burst=50"Strip path prefix (when your app doesn’t know it’s behind a path):
- "traefik.http.middlewares.strip-api.stripprefix.prefixes=/api"- "traefik.http.routers.app.rule=Host(`yourdomain.com`) && PathPrefix(`/api`)"- "traefik.http.routers.app.middlewares=strip-api"Chain multiple middlewares with commas:
- "traefik.http.routers.app.middlewares=auth,ratelimit"The Dashboard: Useful but Secure It
The Traefik dashboard shows you every router, service, and middleware Traefik has discovered. It’s invaluable for debugging. It’s also a gift to anyone who finds it open on the internet.
Expose it behind auth:
traefik: labels: - "traefik.enable=true" - "traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.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=auth" - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$..."Note service=api@internal — that’s the special built-in service name for the dashboard. You don’t define this one yourself.
The Docker Network Problem (and Why It Bites Everyone)
Traefik discovers containers via the Docker socket, but it can only proxy to them if they share a network. This is the number one source of “it’s not routing” bugs.
The pattern that works:
- Create an external network:
docker network create proxy - Attach Traefik to it
- Attach every proxied service to it
- In
traefik.yml, setproviders.docker.network: proxyso Traefik uses that network for routing
If a service is on multiple networks, the network setting in traefik.yml tells Traefik which one to use. Without it, Traefik picks one at random and you get intermittent failures that are absolutely maddening to debug.
Common Label Mistakes
Forgot traefik.enable=true: With exposedByDefault: false in your provider config (which you should have), every service needs this label. Without it, Traefik pretends the container doesn’t exist.
Wrong network: Container and Traefik aren’t on the same network. Router shows up in the dashboard but returns 502. Check with docker network inspect proxy.
Duplicate router names: Two services with the same router name. Traefik will apply one and ignore the other. Use the service name as the router name: traefik.http.routers.myapp.*.
Missing port label when a service exposes multiple ports: Traefik picks one arbitrarily. Be explicit:
- "traefik.http.services.myapp.loadbalancer.server.port=8080"Dollar signs not escaped: If basic auth hash contains $ and you put it in a Compose file, double every $ to $$. If you use an env file or shell variable, single $ is fine.
Using insecure: true on the dashboard in production. Just don’t.
Putting It All Together
Here’s a realistic example: a private app behind auth with HTTPS via Cloudflare DNS challenge.
version: "3.8"
services: myapp: image: myapp:latest labels: - "traefik.enable=true" - "traefik.http.routers.myapp.rule=Host(`app.yourdomain.com`)" - "traefik.http.routers.myapp.entrypoints=websecure" - "traefik.http.routers.myapp.tls.certresolver=cloudflare" - "traefik.http.routers.myapp.middlewares=auth,ratelimit" - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$..." - "traefik.http.middlewares.ratelimit.ratelimit.average=50" - "traefik.http.services.myapp.loadbalancer.server.port=3000" networks: - proxy
networks: proxy: external: trueNo port mapping needed on the host — Traefik reaches the container directly over the Docker network. Your app port never touches the outside world.
The Mental Model, Again
Once the model sticks, the labels write themselves:
- Give every service a router name (use the service name)
- Assign an entrypoint (
weborwebsecure) - Write the rule (usually just
Host()) - Add TLS certresolver if on 443
- Chain middlewares if needed
- Set explicit port if the image exposes multiple
The label syntax is verbose, not complex. It’s traefik.http.routers.<name>.<field> every time. Once you see the pattern, you stop copying from Stack Overflow and start writing from memory.
Your 2 AM self — when something’s not routing and you’re staring at 502s — will appreciate having actually understood this instead of cargo-culting label blocks from the internet.