Skip to main content
RunBook Academy

Docker & ContainersXXIV · Reverse Proxies & TLSTraefik

Traefik — container-native reverse proxy

Intermediate⏱ ~26 mindocker

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

Not yet marked complete on this device.

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:

ObjectAnswersLabel prefix
EntryPointwhich port and protocol traffic arrives onstatic config only, not a label
Routerwhich requests match, and on which entrypointtraefik.http.routers.<name>.
Middlewarewhat to do to a matching request before forwardingtraefik.http.middlewares.<name>.
Servicewhich container and port to forward to, and how to check ittraefik.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

Configuration changecompose.yaml
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.”

Configuration changethe mitigation: a filtering socket proxy
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.

Read-only / Safediagnose a 404
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 traefik

In order of frequency, a 404 is:

  1. traefik.enable=true missing, with exposedByDefault=false. The container is invisible.
  2. The rule does not match. Host(`app.example.com`) is exact; www.app.example.com does not match it. The backticks inside the rule are Traefik’s own syntax and are mandatory.
  3. 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 :80 what you meant to publish only on :443.
  4. A typo in the router name segment. routers.web.rule and routers.wed.entrypoints build 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.

Read-only / Safeprove the network path
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 UNREACHABLE

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

ChallengeNeedsUse when
tlschallengeinbound :443 reachable from the internetthe common case
httpchallenge (with .entrypoint=web)inbound :80 reachable:443 is not free, or you need it before TLS is up
dnschallengeAPI credentials for your DNS providerwildcard 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.

Configuration changeacme storage on a bind mount
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

Read-only / Safethe routing table Traefik actually built
$ 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.

Read-only / Safefour checks that can fail
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

SituationTraefikNote
Containers created and destroyed on every deployyesthis is the case it exists for
You want certificates without a second toolyesACME is built in, including DNS-01 wildcards
The proxy must not talk to the Docker daemonnothat connection is the product
Configuration must be reviewable in one diffnorouting is scattered across every service’s labels
L4 / TCP load balancing to a databaselimitedTCP routers exist but the model is HTTP-first; prefer HAProxy
The proxy should also serve static filesnoTraefik forwards; it does not serve content
A single, rarely changing set of backendsnoyou 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

  1. Q1. What does mounting the Docker socket with `:ro` prevent?

  2. 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?

  3. Q3. Which are accurate about Traefik in front of containers? Select all that apply.

  4. 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.