, 12 min read, by

Reverse Proxy for Containers: Traefik, Caddy, nginx

Caddy and Traefik compared head to head, plus working Docker Compose stacks for both: labels, Caddyfile, automatic HTTPS, common pitfalls and a pointer to nginx.

Anyone running several containers on one host quickly faces the same question: how do you get all of them behind one domain, each with valid HTTPS? In the Docker world there are three common candidates. Traefik reads its routes from container labels, Caddy from a tiny text file, and nginx works from a hand-written configuration. This post puts Traefik and Caddy side by side and then walks through a working minimal stack with Docker Compose for each. nginx has its own post: nginx as a reverse proxy in a container.

Updated 19 October 2026: This article now also covers the content of the former posts "Traefik as a Reverse Proxy: Four Services Behind One Domain with Automatic HTTPS" and "Caddy: Automatic HTTPS as a Lean Reverse-Proxy Alternative".

What both do equally well

The similarities first, because they are bigger than the marketing pages suggest. Both are written in Go and ship as a single binary or a slim container image. Both obtain certificates from Let's Encrypt via ACME on their own, renew them in time and redirect HTTP to HTTPS. Both speak HTTP/3: Caddy enables it by default, Traefik needs a single http3 option on the entrypoint. Both are open source and at home in the Docker world. In other words: for "get two or three containers online securely", either is the right answer. You won't go wrong with one or the other.

The core difference: where does configuration come from?

The real difference is not a feature list but a philosophy.

Caddy thinks in a file. The Caddyfile describes the desired state: one block per service, usually a single reverse_proxy line. When a service is added, you edit the file and apply it with a zero-downtime caddy reload. That is deliberately simple and exactly right for a handful of stable services.

Traefik thinks in events. It watches a provider (with the Docker provider, the Docker socket) and builds its routes dynamically from labels. When a container with matching labels starts, the route exists; when it stops, the route disappears. The routing rule lives right next to the service in your compose file, not in a central proxy configuration. With many or frequently changing containers, this is where Traefik shines.

To be fair: Caddy can be switched to label-based operation via a community plugin (caddy-docker-proxy), but that is an add-on, not a built-in.

Head to head

Caddy Traefik
Configuration Caddyfile (central file, caddy reload) dynamic from providers (Docker labels etc.), no reload
Docker integration file + reload; labels only via community plugin native Docker provider, routes follow containers
Certificates Let's Encrypt and ZeroSSL with automatic failover Let's Encrypt via TLS, HTTP or DNS challenge
Wildcard certificates DNS challenge, provider as module (caddy-dns) DNS challenge, many providers built in
On-demand TLS (customer domains) ✔ built in (certificate at first handshake) no
Static files / web server ✔ full web server (file_server) no (proxy only)
Ready-made middlewares basics on board, much via modules extensive: BasicAuth, RateLimit, Retry, circuit breaker, ForwardAuth, IPAllowList …
Dashboard no (admin API) ✔ built-in dashboard
Kubernetes no established ingress controller ✔ established: Ingress, CRD, Gateway API
Local testing built-in local CA (tls internal, .localhost) Let's Encrypt staging
Learning curve very flat flat to start, more concepts (static/dynamic, routers, middlewares)

What only Caddy can do

Two things stand out. First, Caddy is a full web server: serving a static site while proxying two containers next to it is one instance and three lines of Caddyfile. Traefik cannot do that; it only proxies. Second, on-demand TLS: Caddy can obtain a certificate at the moment of the first TLS handshake, without the domain being configured beforehand. If your customers point their own domains at your application (white-label/SaaS), this is a unique capability that otherwise requires elaborate custom tooling. Add the built-in local CA for HTTPS testing without internet access and without Let's Encrypt; what that looks like is shown below in the Caddy setup.

What only Traefik can do

Traefik is the better platform. Its toolbox of routers and ready-made middlewares (rate limiting, basic auth, retry, circuit breaker, ForwardAuth for SSO gateways) covers, without extra modules, what growing setups eventually need. The built-in dashboard shows at any time which routes were created from which labels. That is worth gold when debugging. And if Kubernetes is on your path, Traefik is an established ingress controller including Gateway API support; a comparably widespread Caddy ingress does not exist. Beyond Docker and Kubernetes, Traefik also reads configuration from Nomad, Consul, ECS or key-value stores.

Verdict: which tool for which job?

Pick Caddy if …

  • you publish a handful of stable services and want the simplest working solution,
  • static content and proxying should come from one tool (web server included),
  • customers connect their own domains (on-demand TLS),
  • you test a lot locally or internally with HTTPS (built-in CA).

Pick Traefik if …

  • containers come, go and move frequently and routes should follow services automatically,
  • you need middlewares like rate limiting, ForwardAuth or circuit breakers without tinkering,
  • a dashboard for runtime diagnostics matters to you,
  • Kubernetes is on the roadmap, in which case there is hardly a way around Traefik.

As a rule of thumb: Caddy is the fastest path to "runs securely", Traefik scales best with you. You can try both in under 15 minutes. The working minimal stacks follow in the next sections.

Common prerequisites for both stacks

Both examples use the test image traefik/whoami as the backend (the Traefik stack adds two nginx:alpine containers). For public certificates to be issued, the DNS A records of the hostnames you use must point at the host and the required ports must be reachable from outside. The proxy then requests the certificates automatically on first access, and a request such as https://app.example.com returns the whoami output over valid HTTPS, without you writing a single certificate line.

Setting up Traefik

The goal: four services behind one domain, automatic TLS via Let's Encrypt, and no configuration file at all for a basic setup. With Traefik, the split into two levels matters. The static configuration defines how Traefik itself starts: entrypoints, providers and certificate resolvers. It rarely changes and is set here via CLI flags. The dynamic configuration describes the actual routes and is read at runtime from the container labels. This split explains why new services appear without restarting Traefik: only the dynamic level changes.

The request flow looks like this:

                          +-----------------------+
   Internet  --:443-->    |        Traefik        |
   (HTTPS)               |  entrypoint websecure  |
                          +-----------+-----------+
                                      |
              +-----------+-----------+-----------+-----------+
              |           |           |           |
        api.example   app.example  www.example  www.example
              |           |           |          /docs
          [ api ]     [ app ]     [ web ]     [ docs ]

The Compose stack

We need exactly one file. Traefik itself is driven by CLI flags; the routes come via labels from the four services.

networks:
  proxy:

services:
  traefik:
    image: traefik:v3.7
    command:
      # Docker provider: read routes from labels
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=proxy"
      # Entrypoints: HTTP and HTTPS
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      # Redirect all HTTP to HTTPS
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      # Let's Encrypt via TLS-ALPN challenge
      - "--certificatesresolvers.le.acme.email=admin@example.com"
      - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.le.acme.tlschallenge=true"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./letsencrypt/acme.json:/letsencrypt/acme.json"
    networks:
      - proxy

  api:
    image: traefik/whoami
    networks: [proxy]
    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=80"

  app:
    image: traefik/whoami
    networks: [proxy]
    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=80"

  web:
    image: nginx:alpine
    networks: [proxy]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.web.rule=Host(`www.example.com`)"
      - "traefik.http.routers.web.entrypoints=websecure"
      - "traefik.http.routers.web.tls.certresolver=le"
      - "traefik.http.services.web.loadbalancer.server.port=80"

  docs:
    image: nginx:alpine
    networks: [proxy]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.docs.rule=Host(`www.example.com`) && PathPrefix(`/docs`)"
      - "traefik.http.routers.docs.entrypoints=websecure"
      - "traefik.http.routers.docs.tls.certresolver=le"
      - "traefik.http.services.docs.loadbalancer.server.port=80"

The labels line by line

Each service gets four related labels:

  • routers.<name>.rule defines which requests the service receives. Host(...) matches the hostname, && combines it with PathPrefix(...). The docs router shares its host with web but wins on /docs, because Traefik gives the more specific (longer) rule higher priority.
  • routers.<name>.entrypoints=websecure binds the route to port 443.
  • routers.<name>.tls.certresolver=le tells Traefik to obtain a certificate for this host via the resolver le defined above.
  • services.<name>.loadbalancer.server.port tells Traefik which internal port the container listens on (here 80 for both whoami and nginx).

Note exposedbydefault=false: without traefik.enable=true, a container is not published. That is the safe default. Nothing ends up exposed by accident.

Starting it

The certificate file must exist with correct permissions before the first start:

mkdir -p letsencrypt
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
docker compose up -d

The TLS-ALPN challenge used here requires that port 443 is reachable from outside and belongs exclusively to Traefik, because the challenge runs over exactly that port.

What Traefik made of the labels shows up in the logs (docker compose logs -f traefik). Every new router and every certificate request appears there, so that is the first place to look when a route doesn't take effect.

Three common Traefik pitfalls

1. A shared Docker network. Traefik can only reach a service if both sit on the same network. Here --providers.docker.network=proxy plus networks: [proxy] on every service handles that. Without it you get a 502 Bad Gateway even though the route exists.

2. Let's Encrypt limits while testing. The production CA has strict rate limits. Test against the staging environment first by adding the flag --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory. The certificates won't be trusted, but you won't burn through your quota. For production, remove the flag and empty acme.json once.

3. Permissions on acme.json. Traefik refuses to start if the file is more open than 600 ("permissions are too open"). So create it as a file as shown above and mount it as a file, not as a directory whose permissions get inherited uncontrollably.

Setting up Caddy

The goal: two services behind one domain, fully automatic TLS. With Caddy, HTTPS isn't an option but the default. As soon as you give a domain as the site address, Caddy obtains a certificate on first start, with no extra configuration.

The request flow looks like this:

                  +--------------------+             
  Internet --:443>|        Caddy       |             
  (HTTPS)         |  Automatic HTTPS + |             
                  |  HTTP -> HTTPS     |             
                  +---------+----------+             
                            |                        
           +----------------+----------------+       
           |                                 |       
    app.example.com                   api.example.com
           |                                 |       
       [  app  ]                         [  api  ]   
          :80                               :80      

The Compose stack and the Caddyfile

We need two files: a docker-compose.yml and a Caddyfile. Put the Caddyfile into a subfolder caddy/ that is mounted as a whole to /etc/caddy (why not the file directly is covered below under pitfalls).

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

  app:
    image: traefik/whoami
    networks: [web]

  api:
    image: traefik/whoami
    networks: [web]

networks:
  web:

volumes:
  caddy_data:
  caddy_config:

The matching caddy/Caddyfile:

{
    email admin@example.com
}

app.example.com {
    reverse_proxy app:80
}

api.example.com {
    reverse_proxy api:80
}

That's the complete stack. The top block with unnamed braces is the global options; it must come first in the Caddyfile. The email inside it records the address for your ACME account with Let's Encrypt (recommended by the Caddy documentation in case there are problems with your certificates; Let's Encrypt stopped sending expiry warning emails on 4 June 2025). Below it sits one block per service: the public address, and reverse_proxy <target>:<port> forwards to the container. app and api are the service names from Compose; on the shared web network Docker resolves them as hostnames. Port 80 is the port whoami listens on inside the container.

Starting up

docker compose up -d

Caddy needs ports 80 and 443 reachable from outside. The issued certificates live in the caddy_data volume under /data; why that directory must persist is covered under pitfalls.

Testing locally without a public domain

Public certificates require the domain to actually point to the host. To test on your own machine there are two easy ways. Use a .localhost address, because Caddy issues a locally trusted certificate for it automatically:

app.localhost {
    reverse_proxy app:80
}

Or force Caddy onto its built-in CA for any internal domain with the tls internal directive:

app.intern.example.com {
    tls internal
    reverse_proxy app:80
}

This way you test the full HTTPS path without contacting Let's Encrypt.

Three common Caddy pitfalls

1. Mount the Caddyfile as a directory, not as a file. Mount the folder (./caddy:/etc/caddy), not the file directly to /etc/caddy/Caddyfile. Otherwise the file's inode changes on edit, and the graceful reload won't pick up the change. This is the official recommendation from the Caddy docs.

2. /data must be persistent. If the caddy_data volume is lost, Caddy loses all certificates and account keys and requests everything anew. With frequent restarts you'll run into Let's Encrypt's rate limits. Treat /data like data, not like a cache.

3. HTTP/3 needs 443/udp. The HTTP/3 that Caddy offers out of the box requires UDP port 443 to be open. That's why "443:443/udp" sits alongside "443:443" in the Compose file. Without it, HTTPS over TCP works, but HTTP/3 silently doesn't.

Where to go from here

For Traefik, the obvious next steps are: secure the dashboard, add middlewares for headers, rate limiting or basic auth, and switch to the HTTP challenge if port 443 isn't exclusively free for Traefik. What a ForwardAuth middleware with SSO in front of your services looks like is shown in Authelia or Authentik: SSO forward-auth. The label reference for the Docker provider is in the official documentation.

For Caddy, check changes with caddy validate before rolling them out and format the Caddyfile with caddy fmt before caddy reload picks it up. Go deeper in the Caddyfile documentation, the reverse_proxy directive, and under Automatic HTTPS. The official Docker image with its Compose example is documented on Docker Hub and under Running Caddy.

If you would rather see every routing decision spelled out in a text file instead of reconstructing it from labels, the classic route is covered in nginx as a reverse proxy in a container. The entry point to all Traefik concepts is the Traefik documentation.

Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the legal notice).

Let's talk

In a free initial consultation, you describe your situation to me. I will give you an initial assessment of how I can support you.

Request a free initial consultation