Overview

Nginx and Caddy both require you to write a config file listing every route. Traefik doesn't. It watches the Docker socket, reads container labels, and builds the routing table dynamically. Add a container, get a route. Remove it, the route disappears.

For a small setup this is overkill. For anything running more than a handful of containers, it eliminates a whole class of "I updated the config but forgot to reload" bugs.

What makes it different

NginxCaddyTraefik
Config styleStatic filesStatic filesDynamic discovery
Route changesReload requiredReload requiredAutomatic
Docker integrationManual labelsManual labelsBuilt-in provider
Auto HTTPSCertbotBuilt-inBuilt-in
MiddlewareConfig blocksConfig blocksComposable, label-based
DashboardNoneNoneBuilt-in web UI

The dashboard is the thing that sells people. You get a live view of every router, service, and middleware, with health status and request metrics. When something isn't routing correctly, you can see why in the browser instead of grepping config files.

The minimal setup

services:
  traefik:
    image: traefik:v3.2
    container_name: traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    command:
      - "--api.dashboard=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.le.acme.email=you@example.com"
      - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.le.acme.tlschallenge=true"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.tls.certresolver=le"
      - "traefik.http.routers.dashboard.middlewares=auth"
      - "traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_AUTH}"

Points worth explaining:

exposedbydefault=false is the correct setting. Without it, every container on the Docker host gets a public route, including your Database. With it, you opt in per container by adding traefik.enable=true.

The Docker socket mount is read-only. Traefik only needs to read, not write. Mounting it read-write gives Traefik more access than it needs, which matters because a compromised Traefik process with a writable socket is root on the host.

The ACME storage file needs mode 600. Create it before starting:

touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json

Without this, the file is world-readable, Traefik refuses to write to it, and you get a confusing error about permissions on first certificate issuance.

Routing a container

services:
  whoami:
    image: traefik/whoami
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=le"
      - "traefik.http.services.whoami.loadbalancer.server.port=80"

Start the container. Within a second, Traefik picks up the labels, creates a router, gets a certificate, and starts serving whoami.example.com. No config reload, no restart, no manual certificate step.

The loadbalancer.server.port label is important. If the container exposes multiple ports, Traefik doesn't know which one to route to. Specify it explicitly and the routing is deterministic.

Middleware: where Traefik gets interesting

Middleware are reusable transformations that apply to requests before they reach a service. They're the equivalent of Nginx's location blocks, but composable and defined per container.

Redirect HTTP to HTTPS

labels:
  - "traefik.http.routers.myapp.entrypoints=web,websecure"
  - "traefik.http.routers.myapp.middlewares=redirect-to-https"
  - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
  - "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"

Basic auth on a single container

# Generate the hash
echo $(htpasswd -nbB admin yourpassword) | sed -e 's/\$/\$\$/g'
labels:
  - "traefik.http.middlewares.myapp-auth.basicauth.users=admin:$$2y$$05$$..."
  - "traefik.http.routers.myapp.middlewares=myapp-auth"

The double-dollar escaping in Compose is annoying but necessary — single dollars get interpreted as variable expansion. This has tripped me up more than once.

Rate limiting

labels:
  - "traefik.http.middlewares.myapp-ratelimit.ratelimit.average=100"
  - "traefik.http.middlewares.myapp-ratelimit.ratelimit.burst=50"
  - "traefik.http.routers.myapp.middlewares=myapp-ratelimit"

Composing middleware

labels:
  - "traefik.http.middlewares.myapp-chain.chain.middlewares=redirect-to-https,myapp-auth,myapp-ratelimit"
  - "traefik.http.routers.myapp.middlewares=myapp-chain"

Chains let you group middleware and reuse them. For an API with several endpoints, define the chain once and attach it to each router.

Wildcard certificates via DNS challenge

The TLS challenge works for individual hostnames. For wildcards, you need DNS challenge, which means Traefik needs API access to your DNS provider.

command:
  - "--certificatesresolvers.le.acme.dnschallenge=true"
  - "--certificatesresolvers.le.acme.dnschallenge.provider=Cloudflare"
  - "--certificatesresolvers.le.acme.dnschallenge.resolvers=1.1.1.1:53"
  - "--certificatesresolvers.le.acme.email=you@example.com"
  - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
environment:
  - CF_API_TOKEN=${CF_API_TOKEN}
labels:
  - "traefik.http.routers.myapp.rule=Host(`app.example.com`) || Host(`api.example.com`)"
  - "traefik.http.routers.myapp.tls.domains[0].main=example.com"
  - "traefik.http.routers.myapp.tls.domains[0].sans=*.example.com"

The wildcard setup means one certificate covers every subdomain, which is convenient when you're spinning up new containers frequently. The tradeoff is that anyone with your Cloudflare API token can obtain a certificate for any subdomain — keep the token scoped to DNS edit only.

The dashboard

Enable it in the config (shown in the minimal setup) and visit https://traefik.example.com. You'll see:

  • Routers — every rule, with the container it came from and the status
  • Services — the backends behind each router, with health and load balancing info
  • Middlewares — every middleware in use and which routers attach it

When a route isn't working, this is where you look first. Ninety percent of the time, the issue is visible: the rule doesn't match, or the container's labels aren't being picked up, or the service port is wrong.

Never expose the dashboard without auth. It reveals every route, service, and container name on your host. Use the basicauth middleware or put it behind a VPN. I've seen exposed Traefik dashboards as a way to enumerate internal services on otherwise well-secured infrastructure.

When to use Traefik, and when not to

Traefik is the right choice when:

  • You're running a dynamic set of containers that change frequently
  • You want a single place to manage TLS, auth, and rate limiting across services
  • Your team rotates containers on deploy and manual config updates are error-prone
  • You want the dashboard for visibility

It's the wrong choice when:

  • You have a fixed set of services that rarely change. Nginx or Caddy with a static config is simpler.
  • You need extremely fine-grained performance tuning. Nginx has more knobs.
  • You don't use containers at all. Traefik works with file-based providers too, but you lose the main benefit.
  • Your team has deep Nginx expertise. Rewriting configs in labels has a learning cost.

The gotcha that bit me

Traefik reads Docker labels at container start. If you update a label without recreating the container, nothing happens. docker compose restart doesn't pick up label changes; you need docker compose up -d --force-recreate.

I spent an hour once wondering why a routing change wasn't taking effect. The labels were in the file, the container was running, but Traefik was still using the old config because the container hadn't been recreated. Read the logs on Traefik's side — it prints every route change — and you'll see this immediately.