Skip to content

Traefik v3: Routing Multiple Docker Apps on One Server

Running five Docker apps on one server creates an immediate problem: which port goes to which service, and how do you keep it from becoming a mess as the stack grows? The usual answer is Nginx with manually maintained config blocks. That works until a container restarts with a new IP and your proxy is pointing at nothing.

Traefik solves this by watching the Docker socket directly. When a container starts with the right labels, Traefik builds the route. When it stops, the route disappears. No config reload, no manual upstream blocks, no downtime.

This post covers a production-ready Traefik v3 setup with named entrypoints per service, a locked-down dashboard, and Docker-label-based routing. Every other post in this series builds on this foundation.

Assumed knowledge: Docker Compose basics, what a reverse proxy does.

Difficulty: Intermediate

Assumes working knowledge of Docker, Linux, and basic networking.

Prerequisites

  • Docker Engine 24+
  • Docker Compose v2
  • A Linux server (Ubuntu 22.04+ used throughout)

If you are running this on a public VPS, read Securing a Public-Facing Dev Stack alongside this post. Traefik opens ports, and you need to know which ones should be public before you start.

Versions used in this post

Traefik v3.2


Background

Traefik has two config layers that behave very differently.

Static config (traefik.yml) loads once at startup and defines entrypoints, providers, and log levels. It cannot be hot-reloaded. Any change requires a Traefik restart.

Dynamic config is discovered continuously at runtime, from Docker labels, config files, or the API. Container changes are picked up immediately with no restart required.

The Docker provider watches the socket. When a container starts with traefik.enable=true, Traefik reads its labels and builds the route. When the container stops, the route disappears. You define your entrypoints once in the static config, then route each service by adding labels to its container.

Never set exposedByDefault: true

With this enabled, Traefik routes every container that does not explicitly opt out, including your database. A Postgres container becomes a public HTTP endpoint. Always set exposedByDefault: false and opt in per service with traefik.enable=true.


Implementation

1. Define the shared Docker network

All services that Traefik needs to route to must share a network. Define it once here. External Compose stacks attach to it with external: true.

docker-compose.yml
networks:
  infra_net:
    driver: bridge
    name: infra_net

volumes:
  traefik_logs:

The explicit name: infra_net is required. Without it, Docker prefixes the name with the Compose project name and external references break.

2. Static configuration

This file is mounted into the Traefik container read-only. It does not change at runtime.

traefik/traefik.yml
api:
  dashboard: true
  insecure: true

entryPoints:
  web:
    address: ":80"
  traefik:
    address: ":8080"
  ams-api:
    address: ":8001"
  ams-frontend:
    address: ":8002"
  hms-api:
    address: ":8003"
  hms-frontend:
    address: ":8004"
  mail-server:
    address: ":8005"

providers:
  docker:
    exposedByDefault: false
    network: infra_net

log:
  level: INFO

accessLog: {}

One entrypoint per service keeps firewall rules simple

Each named entrypoint maps directly to one firewall rule. Open port 8001 to expose one app. Close it to take that app offline. The port itself is the access control, with no middleware or routing rules to manage on top of it.

3. Traefik service in Docker Compose

docker-compose.yml
services:
  traefik:
    image: traefik:v3.2
    container_name: traefik
    restart: unless-stopped
    ports:
      - "0.0.0.0:80:80"
      - "127.0.0.1:8080:8080"
      - "0.0.0.0:8001:8001"
      - "0.0.0.0:8002:8002"
      - "0.0.0.0:8003:8003"
      - "0.0.0.0:8004:8004"
      - "0.0.0.0:8005:8005"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - traefik_logs:/logs
    networks:
      - infra_net
    labels:
      - "traefik.enable=false"

Two things worth noting here. The dashboard port 8080 binds to 127.0.0.1 only, so it is not reachable from the internet. Setting traefik.enable=false on Traefik's own container prevents it from accidentally routing traffic to itself.

4. Routing a service with labels

Labels on a container are how you opt into routing. This routes the Mailpit web UI through the mail-server entrypoint:

docker-compose.yml
  mailpit:
    image: axllent/mailpit:latest
    networks:
      - infra_net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.mailpit.rule=PathPrefix(`/`)"
      - "traefik.http.routers.mailpit.entrypoints=mail-server"
      - "traefik.http.services.mailpit.loadbalancer.server.port=8025"

The three labels do exactly this: opt the container in, match all requests arriving on the mail-server entrypoint, and forward them to port 8025 inside the container. Traefik infers the container's IP from the shared Docker network. You never specify an IP address directly.

5. Accessing the dashboard via SSH tunnel

The dashboard runs on port 8080, bound to 127.0.0.1. Forward it to your local machine over SSH:

terminal
ssh -L 8080:127.0.0.1:8080 user@your-server

Open http://localhost:8080/dashboard/ in your browser. You can inspect every router, service, and middleware Traefik has discovered without touching any config files.

Never bind the dashboard to 0.0.0.0

In insecure: true mode, the dashboard exposes your full routing table, container topology, and middleware configuration with no authentication. Anyone who can reach it sees every route you have defined, including internal service names. SSH tunnel only.


Testing

Bring the stack up:

terminal
docker compose up -d

Check that Traefik has discovered your routes:

terminal
curl -s http://localhost:8080/api/http/routers | jq '.[].name'
expected output
"mailpit@docker"
"api@internal"

Test a route end-to-end. This hits the mail-server entrypoint and should return a 200:

terminal
curl -I http://your-server:8005
expected output
HTTP/1.1 200 OK

Confirm the Docker provider is running:

terminal
curl -s http://localhost:8080/api/providers | jq 'keys'
expected output
["docker"]

Pitfalls

Route conflicts with PathPrefix(/)

Two services on the same entrypoint both using PathPrefix(/) create an ambiguous match. Traefik routes all traffic to whichever router registered first, and the other receives nothing with no error logged. Using one entrypoint per service eliminates this entirely: each entrypoint owns exactly one router.

Debugging missing routes

If a container does not appear in the dashboard, check in this order: the container is on infra_net, traefik.enable=true is set, and the container's own Compose file specifies networks: [infra_net]. Attaching a container to the network in the Traefik Compose file is not sufficient. The target container must declare the network itself.

The Docker socket gives Traefik full read access to the host

Mounting /var/run/docker.sock:ro allows Traefik to read metadata for every container on the host. The read-only flag prevents write operations, but the Docker socket API has no fine-grained permissions. Any container with socket access can enumerate all other running containers. For high-security environments, use the file provider and remove the socket mount. You lose auto-discovery but shrink the attack surface significantly.


Production considerations

When you are ready to serve traffic on real domains, add certificatesResolvers to traefik.yml to enable Let's Encrypt TLS. Traefik handles certificate renewal automatically. You add one config block in the static config and switch each router label from entrypoints=web to entrypoints=websecure.

For public routes that need rate limiting or authentication, Traefik middlewares handle both without any changes to your application code. The basicAuth middleware accepts a htpasswd string and adds HTTP basic auth to any router in a single label.

Enable structured access logs by replacing accessLog: {} with accessLog: { filePath: /logs/access.log }. This gives you a full request audit trail. When something breaks in production at an inconvenient hour, that log is usually the first place showing what changed.

If the Docker socket should not be accessible at all in your environment, switch to the file provider. Routes are no longer auto-discovered and you manage a dynamic config file manually, but the attack surface shrinks considerably.


Wrapping up

Traefik v3's Docker provider turns container labels into live routes with no restarts and no per-service config files. One entrypoint per service keeps your firewall rules clean and your routing unambiguous. This setup is the foundation the rest of this series builds on.


Comments