SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

Traefik Docker Compose: 5 Apps for One IP

Run five apps behind one IP with Traefik v3 and Docker Compose. Route by Host rules, automate Let's Encrypt TLS, and avoid the acme.json trap blocking issuance.

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: web for :80 and websecure for :443.
  • Routers dey match request (Host(...)) and connect am to service. Traefik dey request certificates for each router through tls.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”.

The compose file

One shared Docker network wey dem name proxy na the backbone. Traefik fit reach container only when both dey on am.

name: edge

networks:
  proxy:
    name: proxy

services:
  traefik:
    image: traefik:v3.7
    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=3000

docker compose up -d, then docker compose logs -f traefik. Every extra app na copy of the gitea block, with e own router name, e own Host(), and e own internal port. A Nextcloud installation wey dey run for Docker with TLS and backups fit join the setup the same way: remove the published ports, attach am to proxy, then make the router labels handle the hostname and certificate.

Five details dey make this setup work well.

exposedByDefault=false make container invisible to 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 network. If you omit am, Traefik fit choose the wrong container IP. The result fit be 502 error wey 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 the web entrypoint permanent by default. So plaintext GET, wey browser dey send, go get 301 redirect to HTTPS. Traefik answer 308 only for other methods, like POST, so the method remain the same during the hop. Port 80 still stay open: ACME HTTP challenge need am, and people wey type hostname without scheme need am too.

The doubled $$ for the basic-auth hash na Compose escaping, e no be typo. Generate am with htpasswd -nbB admin 'your-password' (package apache2-utils), then double every $.

Certificate and the acme.json trap

tlschallenge=true dey choose 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=web

Either one go work. 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 log something wey near this and drop the resolver. E go continue running and routing, but e no go request any certificate. So every hostname go serve TRAEFIK DEFAULT CERT:

ERR The ACME resolve is skipped from the resolvers list error="unable to get ACME account: permissions 644 for /letsencrypt/acme.json are too open, please use 600" resolver=le

The clean fix na the one above: bind-mount the directory and allow 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 traefik

Back up that directory together with your app volumes. If you lose am once, you fit recover; certificates go issue again. But if you wipe am for every redeploy, you go enter the limit of five certificates for the exact same set of hostnames every 7 days.

Use the staging CA while you dey iterate. Uncomment the caserver line, make every route work, then comment am out and delete acme.json so production certificates go request fresh. Production Let's Encrypt allows five duplicate certificates per week for one identical set of hostnames, and e dey throttle repeated failed validations for the same name. Staging issues certificates wey browsers no trust. Your browser go warn you, and that warning na the sign 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-auth

One 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:
      - proxy

Remove socket volume from Traefik and point the provider to the proxy:

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

Traefik 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 enable

Docker 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, wey Traefik serve. No router match am. Based on wetin dey happen pass: container no get traefik.enable=true (wey exposedByDefault=false set); Host() rule no match the name wey you type; router name for one label different from router name for another (routers.gitea.rule and routers.gitea.entrypoints must be the same word); or you put hostname inside single quotes. Traefik v3 dey use backticks or escaped double quotes (\") inside matchers, and e reject single quotes.

502 Bad Gateway. Router match am, but e no fit reach backend. Almost always, container no dey for proxy network; check docker inspect -f '{{json .NetworkSettings.Networks}}' gitea. The other possible cause na wrong loadbalancer.server.port: you give am published port, or app dey listen for another place. Log go 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 im 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 never 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 dey reach Traefik from outside. Usually na firewall for provider level wey dey in front of VPS, not ufw.

Certificates no ever issue, and your DNS dey for Cloudflare with orange cloud turned on. Cloudflare dey terminate TLS for im edge, and TLS-ALPN-01 no fit complete through am. Set the record to DNS-only while e dey issue, or change to DNS-01 challenge with API token. DNS-01 na the only challenge wey fit issue wildcards.

Redirect loop. Something for front of Traefik don already terminate TLS and dey forward plaintext to :80; entrypoint redirect dey send am back to HTTPS. Remove one of the two redirects.

Make e keep running

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, one small systemd unit wey dey run docker compose -f /srv/edge/compose.yml up -d with RemainAfterExit=yes go give you systemctl status edge and control over startup order.

Pin Traefik tag (traefik:v3.7, never latest), and pin branch wey still dey release new versions. As of 2026-10-12, Traefik list v3.7 as the only v3 branch wey still get support. v3.5 release its last version, v3.5.6, on 7 November 2025. Its Docker provider get API version 1.24 hardcoded, and Docker Engine 29 and later reject am with client version 1.24 is too old, so no container go get routing. Traefik 3.6.1 add Docker API version negotiation, na why the compose file above dey use v3.7. 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, bump the tag, docker compose up -d traefik, then monitor the log. If you still dey use v2 tag, Traefik v2 to v3 migration guide go show you every rename, the compatibility mode, and rollback method wey go preserve your certificates.

Back up ./letsencrypt and each app data volume. Traefik no get 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 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 still be 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 (mandatory once exposedByDefault=false don set), say Host() value match the name wey you type, and say router name dey identical for every label wey belong to that app. Traefik v3 also need backticks or escaped double quotes inside the matcher; e no accept single quotes.

Wetin be the difference between 404 and 502 here?

404 mean say routing never happen; 502 mean say router match but backend reject the connection. The usual causes of 502 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 to 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 be equal to root access for the host. The stronger arrangement na docker-socket-proxy container wey we show above. E expose only container and network reads 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. DNS-01 still be the answer when CDN like Cloudflare dey terminate TLS in front of your VPS and the other two challenges never complete.