Skip to content

Installing Traefik v3 with Docker Compose

Stand up Traefik v3 on a single Docker host with the Docker provider, ACME storage, and a dashboard that is not exposed to the public internet.

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

  1. 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
  2. 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
  3. 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
  4. Write docker-compose.yml. Note that the dashboard port is bound to 127.0.0.1 only 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
  5. 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.json not mode 600 — Traefik logs permissions xxx for acme.json are too open and refuses to use it; every cert request fails until you chmod 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 :8080 insecure — 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.