Skip to content
← All posts
· 4 min read·Emre Yurtbay

Caddy: Automatic HTTPS as a Lean Reverse-Proxy Alternative

A minimal Caddy stack with Docker Compose: two services behind one domain, fully automatic TLS – including local testing and common pitfalls.

CaddyDockerDocker ComposeReverse ProxyHTTPSTLSSelf-HostingDevOps

If all you need a reverse proxy for is to put a few containers behind a domain with valid HTTPS, nginx quickly feels fiddly and Traefik with its labels is almost too much again. Caddy targets exactly that gap: the configuration is a tiny text file, and HTTPS isn't an option but the default – Caddy obtains certificates from Let's Encrypt on its own. This post shows a working minimal stack with Caddy 2 and Docker Compose serving two services, in under 15 minutes.

What Caddy does – and when it fits

Caddy is a web server and reverse proxy whose standout feature is Automatic HTTPS: as soon as you give a domain as the address, Caddy obtains a certificate on first start, renews it in time, and redirects HTTP to HTTPS automatically – with no extra configuration. This fits wherever you want to publish a handful of services quickly and securely without manual certificate work or verbose proxy config.

Caddy is configured through the Caddyfile – a deliberately minimalist format. Per published service you write one block of site address plus a directive. For a reverse proxy that's exactly one line: reverse_proxy. The common case needs nothing more.

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 minimal stack

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 – useful for expiry warnings. 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

Once the DNS A records for app.example.com and api.example.com point to the host and ports 80 and 443 are reachable from outside, Caddy requests the certificates on first access. Hitting https://app.example.com returns the whoami output with valid HTTPS, without you writing a single certificate line. The issued certificates live in the caddy_data volume under /data – that directory is not a cache and must persist, otherwise Caddy re-requests after every restart and runs into rate limits.

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 – 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 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. Caddy offers HTTP/3 by default, which 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

You now have a lean reverse proxy with automatic HTTPS. Before rolling out changes, check them with caddy validate and format the Caddyfile with caddy fmt; a running instance adopts new config without interruption via caddy reload. 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.

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 Impressum).

Discuss your project