Docker & ContainersXXIV · Reverse Proxies & TLSTraefik
Traefik — container-native reverse proxy
What you'll learn
- Configure Traefik routing entirely from container labels
- Explain what read access to the Docker socket grants, and apply a real mitigation
- Diagnose the two failures every Traefik deployment hits: 404 and "no available server"
- Configure ACME correctly, including staging, storage permissions and challenge choice
- Set responding and forwarding timeouts, and drain Traefik on shutdown
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
nginx and HAProxy both have the same blind spot: they learn about backends from a file you maintain, or from DNS. Traefik removes the file. It connects to the Docker daemon’s API, subscribes to the event stream, reads labels off every container, and rebuilds its routing table whenever anything changes — with no reload and no restart.
For a host where containers are created and destroyed on every deploy that is the right shape. It also means the proxy holds a connection to the Docker daemon, and that is not a small thing. This lesson covers both, in that order, because you should not deploy the first without having decided about the second.
The three objects Traefik routes with
Every label you will write names one of these:
| Object | Answers | Label prefix |
|---|---|---|
| EntryPoint | which port and protocol traffic arrives on | static config only, not a label |
| Router | which requests match, and on which entrypoint | traefik.http.routers.<name>. |
| Middleware | what to do to a matching request before forwarding | traefik.http.middlewares.<name>. |
| Service | which container and port to forward to, and how to check it | traefik.http.services.<name>. |
EntryPoints live in static configuration because they are process startup decisions — you cannot open a listening port by adding a label to some other container. Everything else is dynamic.
A configuration you can run
services:
traefik:
image: traefik:v3.6
restart: unless-stopped
command:
# --- Providers -------------------------------------------------
- "--providers.docker=true"
# Opt-in routing. Without this, EVERY container on the host with a
# resolvable name gets a router, including your database.
- "--providers.docker.exposedByDefault=false"
# Traefik and the apps share this network; see the 502 section.
- "--providers.docker.network=edge"
# --- EntryPoints -----------------------------------------------
- "--entryPoints.web.address=:80"
- "--entryPoints.websecure.address=:443"
- "--entryPoints.web.http.redirections.entryPoint.to=websecure"
- "--entryPoints.web.http.redirections.entryPoint.scheme=https"
- "--entryPoints.web.http.redirections.entryPoint.permanent=true"
# Trust X-Forwarded-* only from the addresses genuinely in front
# of us. Omitting this means Traefik ignores them, which is the
# safe default; 'insecure' would trust anyone and is not used here.
- "--entryPoints.websecure.forwardedHeaders.trustedIPs=192.0.2.0/24"
# Timeouts. Defaults: read 60s, write 0 (none), idle 180s.
- "--entryPoints.websecure.transport.respondingTimeouts.readTimeout=60s"
- "--entryPoints.websecure.transport.respondingTimeouts.idleTimeout=180s"
# Keep taking requests for 5s after SIGTERM so an upstream LB can
# notice, then give in-flight requests 15s to finish.
- "--entryPoints.websecure.transport.lifeCycle.requestAcceptGraceTimeout=5s"
- "--entryPoints.websecure.transport.lifeCycle.graceTimeOut=15s"
# --- ACME ------------------------------------------------------
- "--certificatesresolvers.le.acme.email=ops@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.tlschallenge=true"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
# Read the socket security section before you keep this line.
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks: [edge]
web:
image: nginx:1.29
restart: unless-stopped
networks: [edge]
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`app.example.com`)"
- "traefik.http.routers.web.entrypoints=websecure"
- "traefik.http.routers.web.tls.certresolver=le"
# Required whenever the container exposes more than one port,
# and good practice always.
- "traefik.http.services.web.loadbalancer.server.port=80"
# Active health check. Traefik stops routing to a container that
# fails this - which Docker's own HEALTHCHECK does not do.
- "traefik.http.services.web.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.web.loadbalancer.healthcheck.interval=10s"
- "traefik.http.services.web.loadbalancer.healthcheck.timeout=3s"
volumes:
letsencrypt:
networks:
edge:The Docker socket is the whole security story
The brief version, stated plainly because it is routinely understated:
Any process that can talk to the Docker API can become root on the host. It can create a container with
--privileged, or one that bind-mounts/and writes to/etc/shadow, or one that mounts the host PID namespace. The Docker socket is not “read access to some container metadata”; it is a root shell with extra steps.
Traefik’s own documentation quotes the Docker security guidance directly: only trusted users should be allowed to control your Docker daemon, and notes that “if Traefik is attacked, then the attacker might get access to the underlying host.”
services:
dockersocket:
image: tecnativa/docker-socket-proxy:0.3.0
restart: unless-stopped
environment:
# Default-deny. Everything not listed here returns 403.
CONTAINERS: 1
EVENTS: 1
VERSION: 1
PING: 1
# Explicitly denied - these are the dangerous ones.
POST: 0
EXEC: 0
IMAGES: 0
VOLUMES: 0
NETWORKS: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# Deliberately NOT published to the host. Internal network only.
networks: [socket]
traefik:
image: traefik:v3.6
command:
- "--providers.docker=true"
- "--providers.docker.endpoint=tcp://dockersocket:2375"
- "--providers.docker.exposedByDefault=false"
networks: [socket, edge]
# Note: no docker.sock mount at all.
networks:
socket:
internal: true
edge:POST: 0 is the line that matters. Container creation, exec, and
every other state change is a POST, so denying the verb denies the
escalation path even if a new endpoint appears in a future API
version. internal: true on the network means the socket proxy has no
route off the host at all.
This moves the trust from “Traefik and every dependency it ships” to “a 20 MB proxy whose only job is an allowlist”. That is not perfect — the socket proxy container itself still holds the real socket — but the container holding it is no longer the one exposed to the internet, which is the substantive change.
The two failures every Traefik deployment hits
404 page not found
Traefik answered, so Traefik is running and reachable. It has no router matching that request.
PROXY=traefik
SVC=web
# Did Traefik see the container at all, and did it accept the labels?
docker compose logs "$PROXY" | grep -i -E 'error|warn' | tail -n 20
# What the daemon is advertising for this container
docker inspect -f '{{json .Config.Labels}}' "$(docker compose ps -q "$SVC")" | tr ',' '\n' | grep traefikIn order of frequency, a 404 is:
traefik.enable=truemissing, withexposedByDefault=false. The container is invisible.- The rule does not match.
Host(`app.example.com`)is exact;www.app.example.comdoes not match it. The backticks inside the rule are Traefik’s own syntax and are mandatory. - The router is on the wrong entrypoint. Without
traefik.http.routers.web.entrypoints=websecure, the router is attached to every default entrypoint, which is usually not what you want and occasionally publishes on:80what you meant to publish only on:443. - A typo in the router name segment.
routers.web.ruleandrouters.wed.entrypointsbuild two different routers, one with no rule and one with no entrypoint. Traefik logs this and carries on.
Bad Gateway / “service … has no server available”
The router matched. Traefik could not reach the container.
PROXY=traefik
SVC=web
for c in "$PROXY" "$SVC"; do
printf '%s: ' "$c"
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' "$(docker compose ps -q "$c")"
done
# Can Traefik actually reach it on the port the label names?
docker compose exec -T "$PROXY" wget -qO- --timeout=3 "http://$SVC:80/healthz" && echo REACHABLE || echo UNREACHABLEIf the two containers share no network name, you have your answer without reading another log line.
ACME: the part that is genuinely easy, and the part that is not
Three challenge types, and the choice is dictated by your network rather than by preference:
| Challenge | Needs | Use when |
|---|---|---|
tlschallenge | inbound :443 reachable from the internet | the common case |
httpchallenge (with .entrypoint=web) | inbound :80 reachable | :443 is not free, or you need it before TLS is up |
dnschallenge | API credentials for your DNS provider | wildcard certificates, or the host is not publicly reachable at all |
Only dnschallenge can issue a wildcard, and only dnschallenge
works for an internal host with no inbound path — which is the reason
most people end up there eventually.
acme.json holds the account key and every private key Traefik has
obtained. Traefik requires it to be mode 600 and refuses to start
otherwise — which is a good check, and one people work around by
loosening permissions instead of fixing ownership.
set -euo pipefail
ACME_DIR=/srv/traefik/letsencrypt
sudo install -d -m 0700 "$ACME_DIR"
sudo touch "$ACME_DIR/acme.json"
sudo chmod 0600 "$ACME_DIR/acme.json"
# Confirm before starting Traefik. Anything other than 600 fails at boot.
stat -c '%a %n' "$ACME_DIR/acme.json"Verification that can fail
$ docker compose exec -T traefik wget -qO- http://localhost:8080/api/http/routers[{"entryPoints":["websecure"],"service":"web@docker","rule":"Host(`app.example.com`)",
"status":"enabled","using":["websecure"],"name":"web@docker","provider":"docker"},
{"entryPoints":["websecure"],"service":"api@docker","rule":"PathPrefix(`/api`)",
"status":"disabled","error":["port is missing"],"name":"api@docker","provider":"docker"}]Illustrative output
"status":"disabled" with an error array is the single most useful
output in Traefik. A router that failed to build does not throw; it is
recorded as disabled with a reason, and the reason is usually exactly
the mistake. Requiring --api.insecure=true to reach this on a
published port is the wrong trade — reach it from inside the container
as above, or put the dashboard behind an authenticated router.
HOST=app.example.com
# 1. Does Traefik consider its own configuration valid?
docker compose exec -T traefik traefik --help >/dev/null && echo 'binary ok'
docker compose logs traefik | grep -iE 'command error|configuration error' && exit 1
# 2. Any router in an error state?
docker compose exec -T traefik wget -qO- http://localhost:8080/api/http/routers | grep -o '"status":"disabled"' | wc -l
# 3. Is the certificate real, or is it Traefik's self-signed placeholder?
echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -noout -issuer -subject -dates
# 4. End to end, following the redirect from :80
curl -sSIL "http://$HOST/" | grep -E '^HTTP/|^location:'Check 3 is the one that matters. When ACME has not succeeded, Traefik
serves a self-signed default certificate rather than failing —
the site is reachable over HTTPS and every browser shows a warning.
An issuer line reading CN = TRAEFIK DEFAULT CERT means ACME never
completed, and the reason will be in the logs.
When Traefik is the right choice
| Situation | Traefik | Note |
|---|---|---|
| Containers created and destroyed on every deploy | yes | this is the case it exists for |
| You want certificates without a second tool | yes | ACME is built in, including DNS-01 wildcards |
| The proxy must not talk to the Docker daemon | no | that connection is the product |
| Configuration must be reviewable in one diff | no | routing is scattered across every service’s labels |
| L4 / TCP load balancing to a database | limited | TCP routers exist but the model is HTTP-first; prefer HAProxy |
| The proxy should also serve static files | no | Traefik forwards; it does not serve content |
| A single, rarely changing set of backends | no | you get label indirection for a problem you do not have |
The label model’s real cost shows up in review, not in operation: a
routing change is a diff on an application service, so the question
“what is routed on this host?” has no single file to answer it. The
/api/http/routers endpoint is the answer, and it only exists at
runtime.
Knowledge check
Knowledge check · 4 questions
Q1. What does mounting the Docker socket with `:ro` prevent?
Q2. A container has correct labels and a matching router, but every request returns 502. `docker exec` into it shows the application serving happily on port 80. What should you check first?
Q3. Which are accurate about Traefik in front of containers? Select all that apply.
Q4. Pointing an ACME resolver at the Let's Encrypt staging CA while iterating avoids exhausting the duplicate-certificate rate limit on the real one.
Passing score: 75%. Answers are checked in this browser.
Where next
Caddy takes the automatic-certificate idea further than Traefik does — HTTPS is not a feature you enable, it is the default you would have to work to turn off — while going back to a static configuration file.