DEV Community

Prince
Prince

Posted on

Traefik vs Caddy on One Docker Host: Same Three Apps, Two Configs

Most "Traefik vs Caddy" debates stay abstract: one is "dynamic", the other is "simple". That is true, but it does not help much when you are staring at a fresh VPS with three containers that all want ports 80 and 443.

So here is a more practical test. Take one small but realistic stack and wire it up twice:

  • app.example.com: a web frontend listening on port 3000
  • api.example.com: an API listening on port 8000, with a /health endpoint
  • admin.example.com: an internal tool that should sit behind basic auth

Requirements for both versions: automatic HTTPS, HTTP to HTTPS redirects, no application ports published on the host, and a health check on the API. Then we compare what it felt like.

The shared starting point

Both setups use one Docker network that the proxy and the apps share. The apps never publish ports; only the proxy binds to the host.

networks:
  web:
    name: web
Enter fullscreen mode Exit fullscreen mode

DNS for all three hostnames points at the server's public IP, and ports 80 and 443 are open in the firewall. Both proxies need port 80 reachable for the HTTP-01 ACME challenge unless you switch to DNS challenges.

Version 1: Traefik

Traefik's model is "configure the proxy once, then describe routes on each container with labels". The static configuration goes on the Traefik container itself:

services:
  traefik:
    image: traefik:v3
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=web
      - --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=ops@example.com
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    networks: [web]
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

Then each service declares its own routing:

  app:
    image: ghcr.io/acme/frontend:1.4.2
    networks: [web]
    labels:
      - traefik.enable=true
      - traefik.http.routers.app.rule=Host(`app.example.com`)
      - traefik.http.routers.app.entrypoints=websecure
      - traefik.http.routers.app.tls.certresolver=le
      - traefik.http.services.app.loadbalancer.server.port=3000

  api:
    image: ghcr.io/acme/api:2.0.1
    networks: [web]
    labels:
      - traefik.enable=true
      - traefik.http.routers.api.rule=Host(`api.example.com`)
      - traefik.http.routers.api.entrypoints=websecure
      - traefik.http.routers.api.tls.certresolver=le
      - traefik.http.services.api.loadbalancer.server.port=8000
      - traefik.http.services.api.loadbalancer.healthcheck.path=/health
      - traefik.http.services.api.loadbalancer.healthcheck.interval=10s

  admin:
    image: ghcr.io/acme/admin:0.9.0
    networks: [web]
    labels:
      - traefik.enable=true
      - traefik.http.routers.admin.rule=Host(`admin.example.com`)
      - traefik.http.routers.admin.entrypoints=websecure
      - traefik.http.routers.admin.tls.certresolver=le
      - traefik.http.routers.admin.middlewares=admin-auth
      - traefik.http.middlewares.admin-auth.basicauth.users=admin:$$apr1$$REPLACE$$WITHHASH
      - traefik.http.services.admin.loadbalancer.server.port=8080
Enter fullscreen mode Exit fullscreen mode

Generate the basic auth hash with htpasswd -nb admin 'your-password' and double every $ so Compose does not treat it as variable interpolation. That doubling trips up almost everyone once.

What stood out:

  • Adding a fourth app never touches the proxy. You add labels, run docker compose up -d, and Traefik picks up the route within seconds.
  • The labels are verbose. Router, service and middleware names must line up exactly, and a typo fails quietly: the route simply does not exist and you get a 404.
  • Mounting the Docker socket gives Traefik broad control over the Docker daemon. On anything shared or important, put a socket proxy such as tecnativa/docker-socket-proxy in between and expose only read access to containers.

Version 2: Caddy

Caddy's model is "one readable file describes every site". The container is minimal:

services:
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [web]
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:
Enter fullscreen mode Exit fullscreen mode

The app services keep their images and the web network, but lose all labels. The routing lives in the Caddyfile:

app.example.com {
    reverse_proxy app:3000
}

api.example.com {
    reverse_proxy api:8000 {
        health_uri /health
        health_interval 10s
    }
}

admin.example.com {
    basic_auth {
        admin $2a$14$REPLACE_WITH_BCRYPT_HASH
    }
    reverse_proxy admin:8080
}
Enter fullscreen mode Exit fullscreen mode

Generate the hash with docker compose exec caddy caddy hash-password. HTTPS, certificate renewal, and the HTTP to HTTPS redirect all happen without a single extra line. The published 443/udp port lets Caddy serve HTTP/3, which it enables by default.

After editing the file, reload without downtime:

docker compose exec -w /etc/caddy caddy caddy reload
Enter fullscreen mode Exit fullscreen mode

What stood out:

  • The whole routing table fits on one screen. A new teammate can read it and understand the server in a minute.
  • The /data volume matters. Lose it and Caddy requests fresh certificates on every restart, which can run you into Let's Encrypt rate limits.
  • Adding an app means editing a central file and reloading. That is a feature for small, stable servers and friction for servers where containers come and go all day.

Side by side

Concern Traefik Caddy
Where routes live Labels on each container One Caddyfile
New app workflow Add labels, up -d Edit file, reload
HTTPS defaults Needs resolver + entrypoint config On by default
Debugging a missing route Dashboard or logs; typos fail silently Config errors fail loudly on reload
Docker socket access Required for discovery Not required
Dynamic environments (Swarm, many short-lived stacks) Strong Possible via the caddy-docker-proxy plugin

How I would choose

Pick Caddy when you have a handful of long-lived services, one or two people managing the server, and you value being able to read the entire configuration at a glance. For most single-VPS setups this is the calmer option, and its error messages are friendlier when something goes wrong.

Pick Traefik when services appear and disappear often, when several people deploy independently and should not need to edit a shared file, or when you are already on Swarm or planning to move towards an orchestrator. The label model scales with the number of teams rather than the number of config files.

If you want a broader breakdown that also covers load balancing strategies, middleware, and performance considerations, this deeper Traefik vs Caddy comparison for Docker goes further than the hands-on test above. And if your real question is less "which proxy" and more "how do I lay out a server that hosts many projects cleanly", this guide on running multiple apps on one VPS covers domains, networks, and resource limits around the proxy.

A few gotchas that apply to both

  1. Do not publish app ports. If api still has ports: ["8000:8000"], it is reachable over plain HTTP on the server IP, bypassing your auth and TLS. Remove it and let the proxy be the only door.
  2. Use service names, not localhost. Inside the proxy container, localhost is the proxy itself. Always target api:8000, never 127.0.0.1:8000.
  3. Pass real client IPs. Both proxies set X-Forwarded-For, but your app framework must be told to trust the proxy. Otherwise rate limiting and logs see every request as coming from the proxy container.
  4. Back up certificate storage. That means acme.json for Traefik and the /data volume for Caddy. Both are small and save you from rate limit trouble after a rebuild.

Either proxy will serve you well. The better question is where you want your routing to live: next to each service, or in one file you can read top to bottom. If you would rather not maintain that layer at all, self-hosted platforms like Peon, Coolify and Dokploy generate the proxy configuration for you, but knowing what they do underneath makes debugging much easier when a certificate refuses to issue at 2 a.m.

Top comments (0)