Skip to content
Go back

Traefik: Docker Routing with Labels

By SumGuy 7 min read
Traefik: Docker Routing with Labels

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.

docker-compose.yml
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: true
traefik.yml
entryPoints:
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: false

Before you start, create the external network and the acme.json file:

Terminal window
docker network create proxy
touch acme.json && chmod 600 acme.json

That 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:

For Cloudflare DNS challenge:

traefik.yml
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:

docker-compose.yml
traefik:
environment:
- CF_DNS_API_TOKEN=your_cloudflare_token_here

Staging 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:

docker-compose.yml
# 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):

docker-compose.yml
- "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):

docker-compose.yml
- "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:

docker-compose.yml
- "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):

docker-compose.yml
- "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:

docker-compose.yml
- "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:

docker-compose.yml
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:

  1. Create an external network: docker network create proxy
  2. Attach Traefik to it
  3. Attach every proxied service to it
  4. In traefik.yml, set providers.docker.network: proxy so 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:

docker-compose.yml
- "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.

docker-compose.yml
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: true

No 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:

  1. Give every service a router name (use the service name)
  2. Assign an entrypoint (web or websecure)
  3. Write the rule (usually just Host())
  4. Add TLS certresolver if on 443
  5. Chain middlewares if needed
  6. 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
Git Hooks You Should Be Using Locally Right Now
Next Post
LLM Quantization: Q4_K_M Isn't Always the Best Choice

Related Posts