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