A first install of Traefik on Docker is almost too easy: one compose file, a couple of labels on the next container, and HTTPS lights up. The operator-grade install is what survives the first restart, the first cert renewal, and the first time a colleague tries to figure out how it works without you in the room. This article walks through a Traefik v3 install on a single Docker host, with the dashboard kept off the public internet, ACME storage persisted on disk with the correct permissions, and labels applied to a sample service so traffic actually flows.
How to verify
After the compose stack is up, the following commands should all succeed.
docker compose -f /opt/traefik/docker-compose.yml ps
docker exec traefik traefik version
ss -lntp | grep -E ':(80|443)\b'
curl -sI http://127.0.0.1/ | head -5
ls -l /opt/traefik/acme/acme.json
# acme.json must be mode 600 and owned by the container UID
A request to a sample service should return a Let’s Encrypt certificate:
openssl s_client -connect whoami.example.com:443 -servername whoami.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
If the issuer says (STAGING) or the file acme.json is mode 644, the install is not finished.
What’s happening
Traefik on Docker has two halves. The static configuration is the proxy itself — entrypoints (the ports it listens on), the providers it watches, the certificate resolvers it can use. That config lives in traefik.yml mounted into the container and is read once at startup. The dynamic side comes from container labels: when a container appears with traefik.enable=true and a Host(...) rule, Traefik creates a router and a service on the fly and starts proxying. The Docker provider watches the socket and reacts to events in real time, which is why no reload is needed when you bring up an app.
Two pitfalls hit every new install. First, the dashboard ships with an insecure: true mode that binds :8080 and serves the entire control plane without auth — useful for the first five minutes, never use it past that. Second, acme.json must be mode 600. If Traefik finds it world-readable it refuses to write, and the first cert request silently fails — you only find out when the staging issuance breaks two weeks later.
The procedure
-
Create the directory layout. The ACME store and logs need to survive container restarts, so they live on the host.
sudo mkdir -p /opt/traefik/{conf,dynamic,acme,logs} sudo touch /opt/traefik/acme/acme.json sudo chmod 600 /opt/traefik/acme/acme.json sudo chown -R 65532:65532 /opt/traefik -
Write the static config to
/opt/traefik/conf/traefik.yml.global: checkNewVersion: false sendAnonymousUsage: false log: level: INFO filePath: /var/log/traefik/traefik.log format: json accessLog: filePath: /var/log/traefik/access.log format: json api: dashboard: true insecure: false entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https permanent: true websecure: address: ":443" http: tls: certResolver: letsencrypt dashboard: address: "127.0.0.1:8082" providers: docker: endpoint: "unix:///var/run/docker.sock" exposedByDefault: false network: edge file: directory: /etc/traefik/dynamic watch: true certificatesResolvers: letsencrypt: acme: email: [email protected] storage: /etc/traefik/acme/acme.json keyType: EC256 httpChallenge: entryPoint: web -
Write a minimal dynamic file with a dashboard router protected by basic-auth. Generate the hash with
htpasswd -nbB admin 'changeme'first, then double every$.# /opt/traefik/dynamic/dashboard.yml http: middlewares: dashboard-auth: basicAuth: users: - "admin:$$2y$$05$$REPLACE_WITH_HASH" routers: dashboard: rule: "Host(`traefik.example.com`)" entryPoints: - websecure service: api@internal middlewares: - dashboard-auth tls: certResolver: letsencrypt -
Write
docker-compose.yml. Note that the dashboard port is bound to127.0.0.1only and the edge network is shared with apps.services: traefik: image: traefik:v3.1 container_name: traefik restart: always ports: - "80:80" - "443:443" - "127.0.0.1:8082:8082" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /opt/traefik/conf/traefik.yml:/etc/traefik/traefik.yml:ro - /opt/traefik/dynamic:/etc/traefik/dynamic:ro - /opt/traefik/acme:/etc/traefik/acme - /opt/traefik/logs:/var/log/traefik networks: - edge networks: edge: name: edge -
Bring it up and add a sample app to confirm wiring.
cd /opt/traefik && docker compose up -d docker run -d --name whoami --network edge \ --label traefik.enable=true \ --label "traefik.http.routers.whoami.rule=Host(\`whoami.example.com\`)" \ --label traefik.http.routers.whoami.entrypoints=websecure \ --label traefik.http.routers.whoami.tls.certresolver=letsencrypt \ traefik/whoami curl -sI https://whoami.example.com/
Common pitfalls
acme.jsonnot mode 600 — Traefik logspermissions xxx for acme.json are too openand refuses to use it; every cert request fails until youchmod 600.- Forgetting
exposedByDefault: false— every container on the host that exposes a port appears as a Traefik route, including database admin UIs you never meant to publish. - Using the HTTP-01 challenge for hosts that are not yet pointed at the box — issuance fails silently, then trips the Let’s Encrypt rate limit; for wildcards or pre-cutover work, switch to DNS-01.
- Doubling
$in basic-auth hashes inside YAML — Docker compose interpolation eats single dollar signs, so the hash that worked on the command line fails inside the file. - Leaving the dashboard on
:8080insecure — once it answers public traffic, anyone can see every route, every service, every backend, and the version of Traefik to fingerprint.
The Docker compose install is fine for a single host serving a handful of apps; once a second host joins or routes start scaling into the dozens, the operator model shifts to a swarm or K8s deployment with proper observability. That is where Stack Harbor’s managed operations sits: we run the Traefik fleet for the clients we operate, with ACME rotation, dashboard ACLs, and access-log shipping tracked the same way we track an Nginx or HAProxy estate. For Kubernetes-native installs see traefik-install-kubernetes.