Traefik: How to Run 5 Apps with One Compose File
Run five apps for one IP with Traefik v3 and Docker Compose. Use Host rules, automatic Let's Encrypt TLS, and avoid the acme.json permission trap.
One IP, five apps, one port 443
Your VPS get one public IPv4 address and one TCP port 443. You wan run Gitea, staging copy of your app, internal dashboard, status page, and webhook receiver for there: five hostnames, one box. Reverse proxy na the process wey dey own :80 and :443, read the Host header for every request, then pass am go the correct container. Traefik dey do this work, and e dey obtain and renew certificate for every hostname without you needing run certbot by hand. Nginx and Caddy fit terminate the same five hostnames well, so if you never decide, e make sense to compare the three proxies based on certificate handling and config cost per app before you connect everything to one of dem.
The main difference between Traefik and an nginx server {} block na where the configuration dey come from. With nginx, you edit one file and reload am, while certificate lifecycle remain separate work. Na this workflow you follow when you issue Let's Encrypt certificates with certbot for nginx, where renewal timer dey outside the web server completely. Traefik Docker provider dey monitor Docker event stream and read labels from your containers: start container wey get Host() rule label, and e fit become routable within one second; stop am, and the route disappear. But na here the trap dey. When configuration dey inside labels, e dey spread across five places at once. If one label wrong, nothing go show error. The container simply no go route, and Traefik no go talk anything.
The four nouns
- Entrypoints na listening sockets. You go define two:
webfor:80andwebsecurefor:443. - Routers dey match request (
Host(...)) and connect am to service. Traefik dey request certificates for each router throughtls.certresolver. - Services na the backend: one container and the port wey e dey listen to inside the Docker network.
- Middlewares dey sit between router and service: basic auth, IP allow-lists, header rewrites, redirects.
Those four nouns na Traefik names for work wey you for otherwise write by hand: router na server_name, service na proxy_pass target, and middlewares na the header and auth directives wey you set yourself when you build nginx reverse proxy server block line by line.
You pass static configuration (entrypoints, providers, ACME) through Traefik command line or inside traefik.yml. If you change am, you must restart Traefik. Dynamic configuration (routers, services, middlewares) dey come from container labels and Traefik dey hot-reload am. If you mix the two, na usually that one dey cause “my flag no do anything”.
Compose file
One shared Docker network wey dem name proxy na the backbone. Traefik fit reach container only when both dey on top am.
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. For every extra app, copy the gitea block, but give am im own router name, im own Host(), and im own internal port. A Nextcloud installation wey dey run for Docker with TLS and backups fit enter the same way: remove im published ports, connect am to proxy, then make router labels handle the hostname and certificate.
Five details for there important well well.
exposedByDefault=false make container hide from Traefik until e get traefik.enable=true. If you leave am out, every container wey you start, including the temporary postgres wey you run to check something, go get route generated for am.
providers.docker.network=proxy tell Traefik which network to use when container dey attached to more than one. If you omit am, Traefik fit choose wrong container IP. This go show as 502, and e fit look like application problem.
loadbalancer.server.port=3000 na the port inside the container; Gitea dey listen on 3000 there. Notice say no app container publish any port; na only Traefik publish port.
The redirect for web entrypoint dey turn plaintext requests into 308 go HTTPS. Port 80 still dey open: ACME HTTP challenge need am, and people wey type hostname without scheme need am too.
The two $$ for the basic-auth hash na Compose escaping, no be typo. Generate am with htpasswd -nbB admin 'your-password' (package apache2-utils), then double every $.
Certificate wey acme.json fit cause wahala
tlschallenge=true dey select TLS-ALPN-01: Let's Encrypt go connect to your box for 443, and Traefik go answer the challenge inside the TLS handshake. The alternative na HTTP-01 for port 80. Replace the tlschallenge line for Traefik command: list with these two:
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=webEither one go work. But both need public DNS for the hostname to already point to your VPS. The certificate authority must 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 na the trap wey fit waste person whole evening. Traefik dey keep its ACME account key and every certificate wey e issue inside one acme.json. If group or everybody fit read that file, Traefik go print line wey nearly look like this, then stop:
error: unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600The clean fix na the one above: bind-mount the directory, and make Traefik create the file by itself with the correct mode. If you create acme.json with touch, your umask make am 644. Repair am for the host:
chmod 600 ./letsencrypt/acme.json
docker compose restart traefikBack up that directory together with your app volumes. If you lose am, you fit recover; certificates go issue again. But if five hostnames need re-issue at once, you fit hit the rate limits.
Use the staging CA while you dey iterate. Uncomment the caserver line. Make every route work first. Then comment am out and delete acme.json so production certificates go request fresh. Production Let's Encrypt dey allow five duplicate certificates per week for the same set of hostnames, and e dey throttle repeated failed validations for the same name. Staging dey issue untrusted certificates. Your browser go warn you, and that warning show say e work, with much looser limits.
Dashboard na control surface, e no be demo
Most quickstarts dey set --api.insecure=true, wey dey serve dashboard for port 8080 without authentication. For server wey get public IP, anybody wey scan am fit see your routing topology, hostnames, middleware names, and backend ports.
The labels for traefik service above na the alternative: route dashboard like any other app, with real hostname, through TLS, and behind basicauth. service=api@internal na wetin connect the router to Traefik built-in API. You fit tighten am more by chaining IP allow-list, wey e apply from left to right. If your office address dey change, set the range to the subnet wey WireGuard VPN wey you self-host for the same VPS dey give out, then access the dashboard only through the tunnel:
- traefik.http.middlewares.office.ipallowlist.sourcerange=10.0.0.7/32
- traefik.http.routers.dashboard.middlewares=office,dashboard-authOne shared basicauth password no dey remain defensible once five apps need their own accounts. The same middleware slot fit take a forwardauth wey hand the decision to Authentik, self-hosted single sign-on server, so dashboard and every route near am dey behind one login wey you fit revoke for one place.
Docker socket dey root
/var/run/docker.sock na API wey fit create container plus mount / from host. Access to am equal to root access for machine, and Traefik need am to read labels.
Keep :ro for the mount, but make the benefit clear: e make the socket file read-only. E no stop POST requests to Docker API from passing through am. The real mitigation na to never give Traefik the socket, and put filtering proxy between dem:
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 socket volume from Traefik and point the provider to the proxy:
--providers.docker.endpoint=tcp://dockerproxy:2375Traefik still get read access to containers and networks, but e no fit create anything.
Firewall, ports, and the rule wey everybody dey get wrong
Two ports dey open, plus SSH:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableDocker published ports dey bypass ufw. Docker dey insert im own iptables rules, and dem dey evaluate am before ufw chains. So container wey start with ports: ["3000:3000"] fit receive traffic from internet even when ufw get deny rule for the same port. The defence na structural arrangement, no be firewall configuration: publish ports from Traefik only, and give every other container networks: [proxy] and nothing else. If something really need reach the host, bind am to loopback, "127.0.0.1:3000:3000".
Troubleshooting: errors wey you go actually see
404 page not found, Traefik serve am. No router match. Based on wetin dey happen pass: container no get traefik.enable=true (with exposedByDefault=false set); Host() rule no match the name wey you type; router name for one label different from router name for another one (routers.gitea.rule and routers.gitea.entrypoints must be the same word); or you put the hostname inside quotes instead of backticks. Traefik v3 need backticks inside matchers.
502 Bad Gateway. Router match, but e no fit reach the backend. Almost always, container no dey for proxy network; check docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. Another possibility na wrong loadbalancer.server.port: you give am a published port, or the app dey listen for another place. The log show the attempt: dial tcp 172.18.0.5:8080: connect: connection refused.
Browser dey warn, and certificate issue to TRAEFIK DEFAULT CERT. No certificate dey 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 never point to the box yet. Fix the record, wait for TTL to expire, then restart Traefik.
Invalid response from http://git.example.com/.well-known/acme-challenge/... for the HTTP challenge: port 80 no dey reach Traefik from outside. Usually na provider-level firewall wey dey in front of the VPS, not ufw.
Certificates never issue, and your DNS dey for Cloudflare with the orange cloud on. Cloudflare terminate TLS for its edge, and TLS-ALPN-01 no fit complete through am. Set the record to DNS-only while e dey issue, or switch to DNS-01 challenge with an API token. DNS-01 na also the only challenge wey fit issue wildcards.
Redirect loop. Something for front of Traefik don already terminate TLS and forward plaintext to :80; the entrypoint redirect send am back to HTTPS. Remove one of the two redirects.
Make e dey run
Docker unit must dey enabled for boot (systemctl is-enabled docker), and restart: unless-stopped go bring the stack back after reboot. If you want explicit control, create small systemd unit wey dey run docker compose -f /srv/edge/compose.yml up -d with RemainAfterExit=yes. This one go give you systemctl status edge and control over startup order.
Pin the Traefik tag (traefik:v3.5, never latest). The upgrade from v2 to v3 change rule syntax and provider names. An unattended latest fit reload config wey e no understand again. Upgrade deliberately: read the migration notes, change the tag, docker compose up -d traefik, then monitor the log. If you still dey use v2 tag, Traefik v2 to v3 migration guide explain every rename, the compatibility mode, and rollback method wey go preserve your certificates.
Back up ./letsencrypt and each app data volume. Traefik no keep any other state wey you no fit rebuild from the compose file.
Scale reach e reach
The first limit 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 write to am, dem go corrupt am. To scale out, you need move certificate storage comot from file, or make another place terminate TLS.
The second one na long-lived connections. Server-sent events, large uploads, and slow clients fit hit the entrypoint response timeouts; --entryPoints.websecure.transport.respondingTimeouts.readTimeout plus its writeTimeout and idleTimeout siblings na the settings wey control am. WebSockets dey pass through without extra configuration.
The third one na disk. --accesslog=true dey write to stdout, and Docker's json-file driver dey keep the logs forever unless you cap am. Set logging.options.max-size for the Traefik service, or write the access log to a file and rotate am.
None of these things need orchestrator. But you need server wey you control, with real IP and ports 80 and 443 open to everybody for internet. One small VPS na the complete dependency list.
FAQ
I still need certbot if I dey run Traefik?
No. Traefik ACME resolver dey request and renew certificate for every hostname wey e route, and e dey store everything for acme.json. Certbot still na the correct tool when nginx or another server dey terminate TLS by itself; if both of dem dey work on the same hostnames, dem go only waste Let's Encrypt rate limits.
Why my container dey return 404 through Traefik?
404 wey Traefik serve mean say no router match the request at all. Check say container get traefik.enable=true (e mandatory once exposedByDefault=false don set), say Host() value match the name wey you type, and say router name dey identical for every label for that app. Traefik v3 still want backticks inside the matcher, no be quotes.
Wetin be the difference between 404 and 502 for here?
404 mean say routing never happen; 502 mean say router match, but backend reject the connection. The common 502 causes na container wey no dey attached to proxy network, and loadbalancer.server.port wey point to published port instead of the port wey app dey listen on inside the container. Access log go show the exact address wey Traefik dial.
Mounting Docker socket as read-only enough?
:ro flag make socket file read-only, but e no make the API behind am read-only, POST requests still dey pass through am, and Docker API access equal root access for the host. The stronger arrangement na docker-socket-proxy container wey we show above. E expose only container and network read operations to Traefik, and e block writes completely.
Traefik fit issue wildcard certificate?
Only through DNS-01 challenge, with API token for your DNS provider. TLS-ALPN-01 and HTTP-01 each validate one hostname, and dem no fit produce wildcard certificate. DNS-01 still na the answer when CDN like Cloudflare dey terminate TLS before your VPS, and the other two challenges no dey complete.