Router Rules
Rules live on routers, in a label or a dynamic config file. Values go in backticks, never quotes. Combine matchers with (and), (or), (not), and parentheses.
| Rule | Matches |
|---|---|
| Requests for exactly that hostname | |
| Either hostname | |
| Both conditions at once | |
| Any path starting with /api | |
| That exact path only | |
| Hostnames by Go regex (v3 syntax) | |
| Paths by Go regex | |
| Exact header match | |
| Header value by regex | |
| Query parameter key and value | |
| HTTP method | |
| Source IP or CIDR range | |
| Everything except /admin |
When two routers both match, the longer rule string wins. Override with (higher wins).
v2 differences: used placeholders instead of plain regex, the matchers were and (plural), and / accepted comma-separated values. In v3 chain them with .
Docker Labels
Labels on the app container, not on Traefik. The Traefik label generator builds these interactively.
| Label | What it does |
|---|---|
| Expose this container (required with ) | |
| The router rule | |
| Listen on this entrypoint only | |
| Serve HTTPS on this router | |
| Get the certificate from this resolver | |
| Attach middlewares, in order | |
| Break a tie between two matching routers | |
| Which container port to forward to | |
| Talk HTTPS to the backend | |
| Sticky sessions via a cookie | |
| Which network to reach the container on | |
| TCP routing (databases, MQTT, SSH) |
A working docker compose setup:
services:
traefik:
image: traefik:v3
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
app:
image: myapp:latest
labels:
- traefik.enable=true
- traefik.http.routers.app.rule=Host(`app.example.com`)
- traefik.http.routers.app.entrypoints=websecure
- traefik.http.routers.app.tls.certresolver=letsencrypt
- traefik.http.services.app.loadbalancer.server.port=3000The router name ( here) is arbitrary; it just has to be the same across that container's labels.
Static vs Dynamic Config
Static config is read once at startup: entrypoints, providers, certificate resolvers, logging. It comes from (looked up in ), CLI flags, or environment variables; pick one style. Dynamic config is routers, services, and middlewares; it comes from labels or watched files and applies without a restart.
# /etc/traefik/traefik.yml (static)
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
file:
directory: /etc/traefik/dynamic
watch: true
api:
dashboard: trueA dynamic file routes to things that are not containers, like a service on the host:
# /etc/traefik/dynamic/api.yml
http:
routers:
api:
rule: Host(`api.example.com`)
entryPoints: [websecure]
service: api
tls:
certResolver: letsencrypt
services:
api:
loadBalancer:
servers:
- url: http://192.168.1.10:3000Changing a dynamic file or a label applies immediately. Changing needs a container restart.
EntryPoints
EntryPoints are the ports Traefik listens on. The names and are convention, not built in.
| Config | What it does |
|---|---|
| Listen on port 80 | |
| Listen on port 443 | |
| UDP entrypoint | |
| Same, as a CLI flag | |
| Make this the default for routers that name none (v3) | |
| Serve HTTP/3 on this entrypoint | |
| Trust X-Forwarded-* only from these IPs | |
| Accept the PROXY protocol from these IPs | |
| Per-entrypoint timeouts |
Behind Cloudflare or another proxy, the client IP arrives in and Traefik ignores it until that proxy's ranges are in . Without that, rules, , and rate limits all see the proxy instead of the visitor.
Redirect all HTTP to HTTPS at the entrypoint, so no per-container labels are needed:
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: httpsMiddlewares
Define a middleware once, attach it to any router with . Reference one from another provider with a suffix: , , .
| Label | What it does |
|---|---|
| Redirect this router's traffic to HTTPS | |
| Basic auth; hash from the htpasswd generator | |
| Delegate auth to Authelia, Authentik, or oauth2-proxy | |
| Pass the identity headers on to the app | |
| Forward the original X-Forwarded-* to the auth server | |
| Remove /api before forwarding | |
| Add a prefix before forwarding | |
| Rewrite the path (pair with ) | |
| Redirect one domain to another | |
| Set HSTS (other headers work the same way) | |
| Set any response header | |
| gzip/brotli responses | |
| Requests per second per client | |
| Cap concurrent requests per client | |
| Retry failed requests | |
| Stop sending to a failing backend | |
| Custom error pages (add and ) | |
| Allow only these IPs ( in v2) |
In a compose file every in a basicauth hash or a regex replacement must be doubled to or compose eats it as a variable.
Let's Encrypt
Certificate resolvers are static config. Traefik requests and renews certificates on its own and stores them in .
certificatesResolvers:
letsencrypt:
acme:
email: [email protected]
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web| Step | How |
|---|---|
| Use the resolver on a router | |
| Persist certificates | Mount a volume over , or they re-issue on every restart |
| Fix the acme.json permissions error | |
| Wildcard certificates | DNS challenge only, plus the domains on the router (see below) |
| TLS challenge instead of HTTP | (port 443 must reach Traefik directly) |
| Test without burning rate limits | |
| Check what got issued | The dashboard's HTTP routers view, or the SSL checker once DNS points at the server |
A wildcard from Cloudflare needs the DNS challenge in static config and an API token in the environment:
certificatesResolvers:
letsencrypt:
acme:
email: [email protected]
storage: /letsencrypt/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"# same compose service
environment:
- CF_DNS_API_TOKEN=... # needs Zone:Read and DNS:Edit on that zone
labels:
- traefik.http.routers.app.tls.certresolver=letsencrypt
- traefik.http.routers.app.tls.domains[0].main=example.com
- traefik.http.routers.app.tls.domains[0].sans=*.example.comLet's Encrypt limits issuance to 5 duplicate certificates a week, so keep in a volume and use the staging CA while testing. "Could not find zone for domain" means the token cannot read that zone, or the apex is delegated to different nameservers than the one you asked for.
TCP & UDP
Non-HTTP services get their own router type on their own entrypoint. TCP rules match on SNI, not on paths or headers.
| Label | What it does |
|---|---|
| Add a TCP entrypoint (static config) | |
| Match anything on that entrypoint (the only rule for plain TCP) | |
| Which entrypoint the router listens on | |
| Backend port | |
| Route by SNI, only possible when the client sends TLS | |
| Hand the TLS session to the backend untouched | |
| Match by source IP (v3) | |
| UDP router; UDP has no matchers, so no rule | |
| UDP backend port |
Protocols without SNI (SSH, Postgres, plain MQTT) can only use , and one entrypoint holds one such router. Give each backend its own port. With Traefik never decrypts, so no middleware or certificate resolver applies to that router.
Kubernetes
Traefik ships as an ingress controller. It reads plain objects, its own CRDs, and the Gateway API. k3s installs it by default.
| Command | What it does |
|---|---|
| Add the official chart repo | |
| Install the controller | |
| Every value the chart accepts | |
| List IngressRoute objects | |
| List Middleware objects | |
| Why a route is not live | |
| Follow the controller log | |
| The external IP requests should point at | |
| Skip the Traefik that k3s bundles | |
| Change k3s's bundled Traefik values |
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: app
spec:
entryPoints: [websecure]
routes:
- match: Host(`app.example.com`) && PathPrefix(`/`)
kind: Rule
middlewares:
- name: strip
services:
- name: app
port: 3000
tls:
certResolver: letsencryptAnnotations do the same job on a plain :
metadata:
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: default-strip@kubernetescrdThe CRD API group is in v3 and in v2; applying v2 manifests to a v3 install fails with "no matches for kind". A middleware in another namespace needs that namespace in the reference and .
Plugins
Plugins are declared in static config, downloaded from plugins.traefik.io at startup, and run as interpreted Go, so no rebuild is involved. Once declared, a plugin is used like any other middleware.
# static config
experimental:
plugins:
bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
version: v1.4.5| Task | How |
|---|---|
| Browse what exists | plugins.traefik.io, the catalog the downloader reads |
| Attach one | |
| Develop locally | with the source in |
| Common picks | crowdsec-bouncer, geoblock, real-ip, fail2ban, rewrite-body |
| "unable to download plugin" | Traefik needs outbound HTTPS at startup; check the version tag exists and the container has DNS |
Plugins load only at startup, so adding one means restarting Traefik, not just editing a label.
CLI & Flags
The binary has two subcommands; everything else is flags. Every flag maps to a static config key and to a environment variable.
| Command | What it does |
|---|---|
| Version, codename, Go version, OS/arch | |
| Exit 0 if this Traefik instance responds (needs ping enabled) | |
| Start with an explicit static config file | |
| Every flag, which is also every static config option | |
| Verbose logs ( is the default) | |
| Log every request to stdout | |
| Enable the dashboard | |
| Serve the dashboard unauthenticated on port 8080 (dev only) | |
| Only route containers labeled | |
| Swarm provider (v2 used ) | |
| Define an entrypoint | |
| Check the version of a running container |
Logs & Metrics
Two separate logs. The Traefik log is about Traefik; the access log is about requests. Both go to stdout unless given a path.
| Config | What it does |
|---|---|
| Levels are DEBUG, INFO, WARN, ERROR (default), FATAL, PANIC | |
| Write the Traefik log to a file | |
| Structured Traefik log | |
| Turn the access log on | |
| Write it to a file | |
| One JSON object per request | |
| Log only failing requests | |
| Log requests that needed a retry | |
| Include request headers ( by default) | |
| Expose /metrics for Prometheus | |
| Per-entrypoint counters (routers and services have their own flags) | |
| Send metrics over OpenTelemetry instead | |
| Tracing; v3 dropped the Jaeger, Zipkin, and Datadog backends for OTLP |
Access logs written to a file inside the container disappear on recreate unless the path is on a volume. only shows what went to stdout.
Healthcheck
Two different things share the name: checking Traefik itself, and Traefik checking your backends.
| Task | How |
|---|---|
| Enable the ping endpoint | in static config, or |
| Docker HEALTHCHECK for Traefik | in compose |
| Check it by hand | returns (with ) |
| Health-check a backend | |
| Backend check interval | (add the same way) |
When a backend fails its check, Traefik takes it out of rotation until it passes again; with one replica that means 404s, so backend healthchecks matter most with multiple replicas.
Dashboard & API
The dashboard shows every router, service, and middleware, and flags configuration errors that logs bury.
| Task | How |
|---|---|
| Enable it | in static config |
| Quick look in dev | Add , open (trailing slash required) |
| Expose it properly | + + a basicauth middleware |
| Full config as JSON | |
| List routers / services | and |
| See why a route is down | Dashboard > HTTP > Routers; errors show in red with the reason |
Never ship on a public host; the dashboard reveals every internal hostname and middleware secret name.
Running & Debugging
Traefik runs as a container, so day-to-day operation is docker compose. The docker compose cheat sheet covers the rest.
| Command | What it does |
|---|---|
| Start or apply compose changes | |
| Restart after editing traefik.yml (label changes need no restart) | |
| Follow the log; add first when hunting a routing issue | |
| Just the errors | |
| See the labels Traefik actually sees | |
| Confirm Traefik and the app share a network | |
| Test a router before DNS points anywhere | |
| Throwaway backend that echoes the headers it received | |
| Upgrade Traefik |
More container commands are on the docker cheat sheet.
v2 to v3
Most setups move over untouched. These are the renames that stop a container from starting or silently drop a route.
| v2 | v3 |
|---|---|
| , | , |
| (plain Go regex) | |
| middleware | |
| , | Gone; use a or middleware |
| , a separate provider | |
| , , | only |
| block | Removed |
Run the old config against v3 with in a throwaway container first; unknown keys are reported by name at startup.
Gotchas
- Rules use backticks around values. Quotes inside fail silently in some setups and loudly in others; backticks always work.
- Set . Without it every container on the socket gets routed, including ones you never meant to publish.
- A 404 from Traefik usually means no router matched (wrong Host, wrong entrypoint); a "Bad Gateway" means the router matched but Traefik cannot reach the container, almost always a network mismatch. Put both containers on one network or set .
- When a container exposes several ports, Traefik picks one arbitrarily. Pin it with .
- doubling in compose applies to basicauth hashes only in compose files, not in dynamic file config. The same hash needs in a label and in a YAML file.
- If HTTPS works but keeps serving the "TRAEFIK DEFAULT CERT", the resolver failed; check the logs for acme errors and confirm port 80 (HTTP challenge) reaches Traefik.
- "Too many redirects" behind Cloudflare means Cloudflare SSL is set to Flexible while Traefik also redirects to HTTPS. Set Cloudflare to Full (strict).
- Upgrading v2 to v3 mostly just works, but placeholder rules, matchers, and middlewares need renaming first.