SSD Nodes Learn
Guides Matt ConnorBy Matt Connor · Updated 2026-07-15

Traefik + Docker Compose: 5 apps, auto TLS

Run five apps behind one IP with Traefik v3 on Docker Compose: routing by Host rule, automatic Let's Encrypt TLS, and the acme.json trap that blocks startup.

One IP, five apps, one port 443

Your VPS has a single public IPv4 address and a single TCP port 443. You want Gitea, a staging copy of your app, an internal dashboard, a status page and a webhook receiver on it — five hostnames, one box. A reverse proxy is the process that owns :80 and :443, reads the Host header on every request and hands it to the right container. Traefik does that, and it obtains and renews a certificate for each hostname without you ever running certbot by hand.

What separates Traefik from an nginx server {} block is where its configuration comes from. With nginx you edit a file and reload, and the certificate lifecycle stays a separate chore — the workflow you follow when you issue Let's Encrypt certificates with certbot on nginx, where a renewal timer lives outside the web server entirely. Traefik's Docker provider watches the Docker event stream and reads labels off your containers: start a container carrying a Host() rule label and it is routable within a second; stop it and the route disappears. That is also the trap. Configuration living in labels lives in five places at once, and a wrong label is silent — the container is simply not routed, and Traefik says nothing.

The four nouns

  • Entrypoints are listening sockets. You will define two: web on :80 and websecure on :443.
  • Routers match a request (Host(...)) and attach it to a service. Certificates are requested per router, via tls.certresolver.
  • Services are the backend — a container and the port it listens on inside the Docker network.
  • Middlewares sit between router and service: basic auth, IP allow-lists, header rewrites, redirects.

Static configuration (entrypoints, providers, ACME) is passed on Traefik's command line or in traefik.yml, and changing it means restarting Traefik. Dynamic configuration (routers, services, middlewares) arrives from container labels and is hot-reloaded. Confusing the two is the usual source of "my flag does nothing".

The compose file

One shared Docker network named proxy is the backbone. Traefik reaches a container only if both sit on it.

name: edge

networks:
  proxy:
    name: proxy

services:
  traefik:
    image: traefik:v3.5
    restart: unless-stopped
    command:
      - --providers.docker=true
      - --providers.docker.exposedByDefault=false
      - --providers.docker.network=proxy
      - --entryPoints.web.address=:80
      - --entryPoints.websecure.address=:443
      - --entryPoints.web.http.redirections.entryPoint.to=websecure
      - --entryPoints.web.http.redirections.entryPoint.scheme=https
      - [email protected]
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.le.acme.tlschallenge=true
      # while you iterate, point at staging so a mistake costs nothing:
      # - --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
      - --api.dashboard=true
      - --log.level=INFO
      - --accesslog=true
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)
      - traefik.http.routers.dashboard.entrypoints=websecure
      - traefik.http.routers.dashboard.tls.certresolver=le
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.middlewares=dashboard-auth
      - traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$apr1$$REPLACE$$THIS

  gitea:
    image: gitea/gitea:1
    restart: unless-stopped
    volumes:
      - ./gitea:/data
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.http.routers.gitea.rule=Host(`git.example.com`)
      - traefik.http.routers.gitea.entrypoints=websecure
      - traefik.http.routers.gitea.tls.certresolver=le
      - traefik.http.services.gitea.loadbalancer.server.port=3000

docker compose up -d, then docker compose logs -f traefik. Each additional app is a copy of the gitea block with its own router name, its own Host() and its own internal port. A Nextcloud install running in Docker with TLS and backups slots in the same way — drop its published ports, attach it to proxy, and let the router labels handle the hostname and the certificate.

Five details there earn their keep.

exposedByDefault=false makes a container invisible to Traefik until it carries traefik.enable=true. Leave it out and every container you ever start — including the throwaway postgres you ran to check something — gets a route generated for it.

providers.docker.network=proxy tells Traefik which network to use when a container is attached to several. Omit it and Traefik may pick the wrong container IP, which surfaces as a 502 that looks like an application fault.

loadbalancer.server.port=3000 is the port inside the container; Gitea listens on 3000 there. Notice that no app container publishes a port at all — only Traefik does.

The redirect on the web entrypoint turns plaintext requests into a 308 to HTTPS. Port 80 stays open anyway: the ACME HTTP challenge needs it, and so do humans who type a bare hostname.

The doubled $$ in the basic-auth hash is Compose escaping, not a typo. Generate it with htpasswd -nbB admin 'your-password' (package apache2-utils), then double every $.

The certificate, and the acme.json trap

tlschallenge=true selects TLS-ALPN-01: Let's Encrypt connects to your box on 443 and Traefik answers the challenge inside the TLS handshake. The alternative is HTTP-01, on port 80:

--certificatesresolvers.le.acme.httpchallenge=true
--certificatesresolvers.le.acme.httpchallenge.entrypoint=web

Either works. Both require that public DNS for the hostname already points at your VPS — the certificate authority resolves the name and connects from outside. Create the A (and AAAA) record first, confirm with dig +short git.example.com, then start Traefik.

Now the trap that costs people an evening. Traefik keeps its ACME account key and every issued certificate in one acme.json. If that file is group- or world-readable, Traefik prints a line very close to this and stops:

error: unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600

The clean fix is the one above: bind-mount the directory and let Traefik create the file itself with the right mode. If you created acme.json with touch, your umask made it 644. Repair it on the host:

chmod 600 ./letsencrypt/acme.json
docker compose restart traefik

Back that directory up with your app volumes. Losing it is survivable — certificates re-issue — but re-issuing five hostnames at once walks you into the rate limits.

Use the staging CA while you iterate. Uncomment the caserver line, get every route working, then comment it out and delete acme.json so production certificates are requested fresh. Production Let's Encrypt permits five duplicate certificates per week for an identical set of hostnames, and throttles repeated failed validations for the same name. Staging issues untrusted certificates — your browser warns, and that warning is the signal it worked — with far looser limits.

The dashboard is a control surface, not a demo

Most quickstarts set --api.insecure=true, which serves the dashboard on port 8080 with no authentication. On a box with a public IP that hands your routing topology, hostnames, middleware names and backend ports to anyone who scans it.

The labels on the traefik service above are the alternative: the dashboard routed like any other app, on a real hostname, over TLS, behind basicauth. service=api@internal is what wires the router to Traefik's built-in API. Tighten it further by chaining an IP allow-list, applied left to right. If your office address is dynamic, set the range to the subnet handed out by a WireGuard VPN you self-host on the same VPS and reach the dashboard only over the tunnel:

- traefik.http.middlewares.office.ipallowlist.sourcerange=203.0.113.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-auth

The Docker socket is root

/var/run/docker.sock is an API that can create a container which mounts / from the host. Access to it is equivalent to root on the machine, and Traefik needs it to read labels.

Keep the :ro on the mount, but be clear about what it buys: it makes the socket file read-only. It does not stop POST requests to the Docker API travelling over it. The real mitigation is to never hand Traefik the socket, and put a filtering proxy in between:

  dockerproxy:
    image: tecnativa/docker-socket-proxy   # pin the current tag
    restart: unless-stopped
    environment:
      CONTAINERS: 1
      NETWORKS: 1
      POST: 0
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - proxy

Drop the socket volume from Traefik and point the provider at the proxy:

--providers.docker.endpoint=tcp://dockerproxy:2375

Traefik keeps read access to containers and networks, and loses the ability to create anything.

Firewall, ports, and the rule everybody gets wrong

Two ports open, plus SSH:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Docker's published ports bypass ufw. Docker inserts its own iptables rules, evaluated ahead of ufw's chains, so a container started with ports: ["3000:3000"] is reachable from the internet with a ufw deny sitting right there. The defence is structural, not firewall configuration: publish ports from Traefik only, and give every other container networks: [proxy] and nothing else. If something genuinely must reach the host, bind it to loopback — "127.0.0.1:3000:3000".

Troubleshooting: errors you will actually see

404 page not found, served by Traefik. No router matched. In order of likelihood: the container lacks traefik.enable=true (with exposedByDefault=false set); the Host() rule does not match the name you typed; the router name in one label differs from the router name in another (routers.gitea.rule and routers.gitea.entrypoints must be the same word); or you wrapped the hostname in quotes instead of backticks. Traefik v3 requires backticks inside matchers.

502 Bad Gateway. A router matched and the backend was unreachable. Almost always the container is not on the proxy network — check docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. The other candidate is a wrong loadbalancer.server.port: you gave it a published port, or the app listens elsewhere. The log names the attempt: dial tcp 172.18.0.5:8080: connect: connection refused.

The browser warns, and the certificate is issued to TRAEFIK DEFAULT CERT. No certificate exists for that hostname and Traefik served its self-signed placeholder. Read the ACME lines:

unable to obtain ACME certificate for domains "git.example.com" ...
acme: error: 400 ... DNS problem: NXDOMAIN looking up A for git.example.com

DNS is not pointing at the box yet. Fix the record, wait out the TTL, restart Traefik.

Invalid response from http://git.example.com/.well-known/acme-challenge/... on the HTTP challenge: port 80 is not reaching Traefik from outside — usually a provider-level firewall in front of the VPS, not ufw.

Certificates never issue, and your DNS is on Cloudflare with the orange cloud on. Cloudflare terminates TLS at its edge and TLS-ALPN-01 cannot complete through it. Set the record to DNS-only while issuing, or switch to the DNS-01 challenge with an API token. DNS-01 is also the only challenge that issues wildcards.

Redirect loop. Something in front of Traefik already terminates TLS and forwards plaintext to :80; the entrypoint redirect sends it back to HTTPS. Remove one of the two redirects.

Keeping it running

Docker's unit must be boot-enabled (systemctl is-enabled docker), and restart: unless-stopped brings the stack back after a reboot. For an explicit handle, a small systemd unit running docker compose -f /srv/edge/compose.yml up -d with RemainAfterExit=yes gives you systemctl status edge and ordering control.

Pin the Traefik tag (traefik:v3.5, never latest). The v2-to-v3 upgrade changed rule syntax and provider names, and an unattended latest will cheerfully reload a config it no longer understands. Upgrade deliberately: read the migration notes, bump the tag, docker compose up -d traefik, watch the log.

Back up ./letsencrypt and each app's data volume. Traefik holds no other state you cannot rebuild from the compose file.

What breaks at scale

The first ceiling is not throughput, it is the single box: one Traefik on one VPS is a single point of failure for five apps, and acme.json is flat-file storage — two Traefik instances writing it will corrupt it. Scaling out means moving certificate storage out of a file, or terminating TLS somewhere else.

The second is long-lived connections. Server-sent events, large uploads and slow clients meet the entrypoint's responding timeouts; --entryPoints.websecure.transport.respondingTimeouts.readTimeout and its writeTimeout and idleTimeout siblings are the knobs. WebSockets pass through with no extra configuration.

The third is disk. --accesslog=true writes to stdout, and Docker's json-file driver keeps that forever unless capped. Set logging.options.max-size on the Traefik service, or write the access log to a file and rotate it.

None of this needs an orchestrator. It does need a server you control, with a real IP and ports 80 and 443 open to the world — a single small VPS is the whole dependency list.

FAQ

Do I still need certbot if I run Traefik?

No. Traefik's ACME resolver requests and renews the certificate for every hostname it routes, and stores the lot in acme.json. Certbot remains the right tool when nginx or another server terminates TLS itself; running both against the same hostnames only burns Let's Encrypt rate limits.

Why does my container return 404 through Traefik?

A 404 served by Traefik means no router matched the request at all. Check that the container carries traefik.enable=true (mandatory once exposedByDefault=false is set), that the Host() value matches the name you typed, and that the router name is identical across every label for that app. Traefik v3 also wants backticks inside the matcher, not quotes.

What is the difference between a 404 and a 502 here?

A 404 means routing never happened; a 502 means a router matched and the backend refused the connection. The usual 502 candidates are a container that is not attached to the proxy network, and a loadbalancer.server.port pointing at a published port rather than the port the app listens on inside the container. The access log names the exact address Traefik dialled.

Is mounting the Docker socket read-only enough?

The :ro flag makes the socket file read-only, not the API behind it — POST requests still travel over it, and Docker API access is equivalent to root on the host. The stronger arrangement is the docker-socket-proxy container shown above, which exposes only container and network reads to Traefik and blocks writes outright.

Can Traefik issue a wildcard certificate?

Only over the DNS-01 challenge, with an API token for your DNS provider. TLS-ALPN-01 and HTTP-01 each validate a single hostname and cannot produce a wildcard. DNS-01 is also the answer when a CDN such as Cloudflare terminates TLS in front of your VPS and the other two challenges never complete.