how to run 5 apps with Traefik v3 Docker Compose
Learn how to route 5 apps with one IP using Traefik v3. We go show you how to use Host rules and avoid that acme.json error wey dey block startup.
One IP, five apps, one port 443
Your VPS get one single public IPv4 address and one single TCP port 443. You want Gitea, one staging copy of your app, one internal dashboard, one status page, and one webhook receiver for am — five hostnames, one box. Reverse proxy na the process wey dey control :80 and :443, e dey read the Host header for every request and e dey send am to the right container. Traefik dey do that work, and e dey get and renew certificate for every hostname without you need to run certbot manually.
Wetin make Traefik different from nginx server {} block na where its configuration dey come from. With nginx, you dey edit file and reload, and certificate lifecycle na separate work — na that workflow wey you dey follow when you issue Let's Encrypt certificates with certbot on nginx, where renewal timer dey run outside the web server entirely. Traefik Docker provider dey watch Docker event stream and e dey read labels from your containers: start one container wey get Host() rule label and e go dey routable within one second; stop am and the route go disappear. Na there the trap dey. Configuration wey dey inside labels dey stay for five places at once, and if label wrong, e no go shout — the container just no go get route, and Traefik no go talk anything.
The four nouns
- Entrypoints na listening sockets. You go define two:
webon:80andwebsecureon:443. - Routers dey match request (
Host(...)) and dem dey connect am to service. Dem dey request certificates for every router throughtls.certresolver. - Services na the backend — na container and the port wey e dey listen on inside the Docker network.
- Middlewares dey sit between router and service: basic auth, IP allow-lists, header rewrites, and redirects.
Static configuration (entrypoints, providers, ACME) na wetin you dey pass for Traefik command line or inside traefik.yml, and if you change am, you must restart Traefik. Dynamic configuration (routers, services, middlewares) dey come from container labels and e dey hot-reload. If you mix these two together, na how "my flag does nothing" dey happen.
The compose file
One shared Docker network wey dem name proxy na him be backbone. Traefik fit reach container only if both dem dey inside that network.
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
- --certificatesresolvers.le.acme.email=you@example.com
- --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 # major-only pin keeps this demo copy-pasteable; pin an exact release in production
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=3000docker compose up -d, then docker compose logs -f traefik. Every extra app na copy of the gitea block, but e go get its own router name, its own Host(), and its own internal port. Nextcloud install wey dey run for Docker with TLS and backups fit join inside the same way — just drop its published ports, attach am to proxy, and let the router labels handle the hostname and the certificate.
Five details for here dey very important.
exposedByDefault=false dey make container invisible to Traefik until e carry traefik.enable=true. If you no put am, every container wey you start — even the one wey you use for quick check like postgres — Traefik go generate route for am.
providers.docker.network=proxy dey tell Traefik which network e suppose use if container dey inside many networks. If you no put am, Traefik fit pick wrong container IP, and you go see 502 error wey go look like say application fault.
loadbalancer.server.port=3000 na the port wey dey inside the container; Gitea dey listen for 3000 for there. Notice say no app container dey publish any port — na only Traefik dey do am.
The redirect for the web entrypoint dey turn plaintext requests to 308 for HTTPS. Port 80 must stay open: ACME HTTP challenge need am, and humans wey dey type bare hostname also need am.
The double $$ for the basic-auth hash na Compose escaping, no be typo. Use htpasswd -nbB admin 'your-password' (package apache2-utils) generate am, then double every $.
The certificate, and the acme.json trap
tlschallenge=true select TLS-ALPN-01: Let's Encrypt go connect to your box for port 443 and Traefik go answer the challenge inside the TLS handshake. The other one na HTTP-01, wey dey use port 80 — change the tlschallenge line for Traefik command: list for these two:
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=webBoth of dem dey work. Dem both need say public DNS for the hostname must point to your VPS already — the certificate authority go resolve the name and connect from outside. Create the A (and AAAA) record first, confirm am with dig +short git.example.com, then start Traefik.
Now, the trap wey dey make people waste whole evening. Traefik dey keep im ACME account key and every certificate wey e don issue inside one acme.json. If that file get permission wey group or world fit read, Traefik go print line wey close to this one and e go stop:
error: unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600The clean fix na the one wey dey above: bind-mount the directory and let Traefik create the file itself with the correct mode. If you create acme.json with touch, your umask make the permission be 644. Fix am for the host:
chmod 600 ./letsencrypt/acme.json
docker compose restart traefikBack up that directory with your app volumes. If you lose am, you fit survive — certificates go re-issue — but if you try re-issue five hostnames at once, you go hit the rate limits.
Use the staging CA while you dey test. Uncomment the caserver line, make sure every route dey work, then comment am out and delete acme.json so that production certificates go request fresh. Production Let's Encrypt dey allow only five duplicate certificates per week for the same set of hostnames, and dem dey throttle repeated failed validations for the same name. Staging dey issue certificates wey browser no go trust — your browser go warn you, and that warning na the signal say e work — and dem get far looser limits.
Dashboard no be demo, na control surface
Most quickstarts dey set --api.insecure=true, wey dey serve dashboard for port 8080 without any authentication. If you use box wey get public IP, anyone wey scan am go see your routing topology, hostnames, middleware names, and backend ports.
The labels wey dey traefik service above na the better way: the dashboard go dey route like any other app, with real hostname, over TLS, and behind basicauth. service=api@internal na wetin dey connect router to Traefik built-in API. You fit tighten security even more by use IP allow-list, wey dey work from left to right. If your office address dey change, set the range to the subnet wey WireGuard VPN wey you self-host on the same VPS dey give, so you fit reach dashboard only through the tunnel:
- traefik.http.middlewares.office.ipallowlist.sourcerange=10.0.0.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-authThe Docker socket na root
/var/run/docker.sock na API wey fit create container wey go mount / from host. If person get access to am, e be like say dem get root access for machine, and Traefik need am to read labels.
Keep the :ro for the mount, but make you understand wetin e dey do: e dey make the socket file read-only. E no go stop POST requests to the Docker API wey dey travel through am. The real way to fix am na make you no give Traefik the socket at all, and instead, put filtering proxy for middle:
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:
- proxyRemove the socket volume for Traefik and point the provider go the proxy:
--providers.docker.endpoint=tcp://dockerproxy:2375Traefik go still get power to read containers and networks, but e no go get power to create anything again.
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 enableDocker ports wey you publish bypass ufw. Docker dey put im own iptables rules, and dem dey work before ufw chains. So, if you start container with ports: ["3000:3000"], internet fit reach am even if ufw dey deny am. The problem na how Docker structure dey, no be firewall configuration: publish ports from Traefik only, and give every other container networks: [proxy] and nothing else. If something really must reach the host, bind am to loopback — "127.0.0.1:3000:3000".
Troubleshooting: errors wey you go see
404 page not found, Traefik serve am. No router match. Based on wetin dey happen most: the container no get traefik.enable=true (wey exposedByDefault=false set); the Host() rule no match the name wey you type; the router name for one label no be the same as the router name for another (routers.gitea.rule and routers.gitea.entrypoints must be the same word); or you use quotes instead of backticks for hostname. Traefik v3 need backticks inside matchers.
502 Bad Gateway. Router match, but backend no reach. Almost always the container no dey the proxy network — check docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. Another reason na wrong loadbalancer.server.port: you give am published port, or the app dey listen for another place. The log go show the attempt: dial tcp 172.18.0.5:8080: connect: connection refused.
The browser go warn you, and certificate go issue for TRAEFIK DEFAULT CERT. No certificate exist for that hostname, so Traefik serve 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.comDNS no still point to the box. Fix the record, wait for TTL to finish, then restart Traefik.
Invalid response from http://git.example.com/.well-known/acme-challenge/... for HTTP challenge: port 80 no reach Traefik from outside — usually na provider-level firewall for front of the VPS, no be ufw.
Certificates no dey issue, and your DNS dey Cloudflare with orange cloud on. Cloudflare dey terminate TLS for its edge, so TLS-ALPN-01 no fit pass through am. Set the record to DNS-only while you dey issue, or use DNS-01 challenge with API token. DNS-01 na the only challenge wey dey issue wildcards.
Redirect loop. Something for front of Traefik don already terminate TLS and dey send plaintext to :80; the entrypoint redirect go send am back to HTTPS. Remove one of the two redirects.
Keeping it running
Docker unit must set to boot-enabled (systemctl is-enabled docker), and restart: unless-stopped go bring the stack back after reboot. If you want full control, use small systemd unit wey run docker compose -f /srv/edge/compose.yml up -d with RemainAfterExit=yes for systemctl status edge and ordering control.
Pin the Traefik tag (traefik:v3.5, no use latest). v2-to-v3 upgrade change how rule syntax and provider names work, so if you use unattended latest, e go reload config wey e no understand again. Upgrade with care: read migration notes, change the tag, docker compose up -d traefik, and watch the log. If you still dey use v2 tag, the Traefik v2 to v3 migration guide go show you every rename, compatibility mode, and how to rollback without lose your certificates.
Back up ./letsencrypt and every app data volume. Traefik no get any other state wey you no fit rebuild from the compose file.
Wetin dey break when scale big
First ceiling no be throughput, na the single box: one Traefik for one VPS na single point of failure for five apps, and acme.json na flat-file storage — if two Traefik instances try write am, e go corrupt. To scale out, you must move certificate storage out of file, or make another place handle TLS termination.
Second one na long-lived connections. Server-sent events, big uploads, and slow clients go hit the entrypoint's responding timeouts; --entryPoints.websecure.transport.respondingTimeouts.readTimeout and its writeTimeout and idleTimeout siblings na the knobs for control am. WebSockets go pass through without extra configuration.
Third one na disk. --accesslog=true dey write to stdout, and Docker's json-file driver go keep that thing forever unless you cap am. Set logging.options.max-size for the Traefik service, or write the access log to one file and rotate am.
None of these things need orchestrator. You just need one server wey you control, with real IP and ports 80 and 443 open to the world — one small VPS na the whole dependency list.
FAQ
I still need certbot if I run Traefik?
No. Traefik ACME resolver dey request and renew certificate for every hostname wey e dey route, and e dey store everything for acme.json. Certbot na still the right tool if nginx or another server dey handle TLS itself; if you run both for the same hostnames, you go just dey waste Let's Encrypt rate limits.
Why my container dey return 404 through Traefik?
If Traefik give 404, e mean say no router match the request at all. Check if container carry traefik.enable=true (this one mandatory once you set exposedByDefault=false), make sure Host() value match the name wey you type, and make sure router name be the same for every label for that app. Traefik v3 also want backticks inside the matcher, no be quotes.
Wetin be difference between 404 and 502 for here?
404 mean say routing no even happen; 502 mean say router match the request but backend refuse the connection. Common reasons for 502 na container wey no join proxy network, and loadbalancer.server.port wey point to published port instead of the port wey app dey listen for inside container. Access log go show the exact address wey Traefik dial.
To mount Docker socket as read-only enough?
:ro flag dey make the socket file read-only, but e no dey make the API behind am read-only — POST requests still dey pass through am, and Docker API access na same level as root for host. Better way na to use docker-socket-proxy container wey we show above, wey dey give Traefik only container and network reads and e dey block all writes.
Traefik fit issue wildcard certificate?
Only through DNS-01 challenge, and you go need API token for your DNS provider. TLS-ALPN-01 and HTTP-01 dey validate single hostname only and dem no fit produce wildcard. DNS-01 na also the solution when CDN like Cloudflare dey handle TLS for front of your VPS and the other two challenges no dey work.