Docker & ContainersXXIV · Reverse Proxies & TLSCaddy
Caddy — automatic HTTPS by default
What you'll learn
- Explain what makes a Caddy site address get a certificate, and what does not
- Persist Caddy state correctly so a redeploy does not re-issue certificates
- Configure trusted proxies so the client IP is recovered rather than ignored
- Add active and passive health checks and bound the timeouts Caddy leaves open
- Choose between Caddy, nginx, HAProxy and Traefik on operational grounds
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
The other three proxies in this part make you ask for HTTPS. Caddy
makes you ask for its absence. Write a hostname as a site address and
Caddy obtains a certificate for it, serves HTTP/2 and HTTP/3 over TLS,
redirects port 80, and renews on its own schedule — with no tls
directive, no ACME configuration and no cron job.
That is a genuine reduction in work. It also relocates every failure. When a certificate does not appear there is no missing line to find, because the mechanism you are debugging was never written down. This lesson is mostly about that.
What actually triggers a certificate
Automatic HTTPS is decided by the site address, and the rule is mechanical:
| Site address | What Caddy does |
|---|---|
app.example.com | public ACME certificate, HTTPS on 443, redirect from 80 |
localhost, *.localhost, 127.0.0.1, [::1] | certificate from Caddy’s own local CA |
:8080 | plain HTTP — a port with no hostname has no name to certify |
http://app.example.com | plain HTTP — the explicit scheme opts out |
app.example.com:8443 | ACME certificate, served on 8443 |
auto_https is a global option that turns the feature down, not
on: off, disable_redirects, disable_certs,
ignore_loaded_certs. There is no directive that enables automatic
HTTPS, because writing the hostname already did.
Caddy enables two ACME issuers by default — Let’s Encrypt and ZeroSSL — and falls through from the first to the second. That has a practical consequence worth knowing before it confuses you: an expiry warning email from Let’s Encrypt may be about a certificate Caddy has already replaced with a ZeroSSL one. Check the certificate, not the email.
A configuration you can run
{
# Contact address on the ACME account. Not optional in practice.
email ops@example.com
servers {
# Without this, Caddy ignores X-Forwarded-For entirely and
# {client_ip} is the address of whatever connected to it.
trusted_proxies static 192.0.2.0/24
# Parse the chain right-to-left. Recommended whenever an
# upstream proxy appends to X-Forwarded-For, which they all do.
trusted_proxies_strict
}
}
app.example.com {
# Compression on the way out. Caddy already does HTTP/2 and HTTP/3.
encode zstd gzip
# Route /api to one container, everything else to another.
handle /api/* {
reverse_proxy api:9090 {
# Active health checking. Nothing polls the backend until
# health_uri is set - the default is passive-only.
health_uri /healthz
health_interval 10s
health_timeout 3s
health_status 200
# Bound the wait for response headers. The default is NO
# timeout, so a hung backend holds the request forever.
transport http {
dial_timeout 3s
response_header_timeout 30s
}
}
}
handle {
reverse_proxy web:8080 {
health_uri /healthz
health_interval 10s
# Passive: eject an upstream for 30s after 3 failures.
fail_duration 30s
max_fails 3
# Hold a request for up to 5s waiting for a healthy
# upstream instead of returning 502 immediately. This is
# what makes a rolling redeploy invisible.
lb_try_duration 5s
lb_try_interval 250ms
}
}
log {
output stdout
format json
}
}services:
caddy:
image: caddy:2.10
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 is UDP. Omit this and QUIC silently never works.
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # certificates + ACME account key. Must persist.
- caddy_config:/config
networks: [edge]
web:
image: nginx:1.29
restart: unless-stopped
networks: [edge]
volumes:
caddy_data:
caddy_config:
networks:
edge:The Caddy-in-Docker failure
The data directory is $XDG_DATA_HOME/caddy when that variable is
set, and the official image sets it. Do not take that on trust —
confirm it on the image you are actually running:
IMAGE=caddy:2.10
# Where will this image put certificates?
docker run --rm "$IMAGE" printenv XDG_DATA_HOME
# Is anything actually there, and is it on a volume?
docker compose exec -T caddy ls -R /data/caddy/certificates 2>/dev/null | head -n 20
docker compose config --format json | python3 -c 'import json,sys; s=json.load(sys.stdin)["services"]["caddy"]; print(s.get("volumes"))'The rule that follows: use a named volume or a bind mount for
/data, and treat docker compose down -v on that stack as a
destructive operation. During development, set the staging CA so it
does not matter:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}Remove that line for production and clear /data when you do,
because the staging account key and its certificates live in the same
directory and Caddy will happily keep serving the untrusted ones.
Forwarded headers: ignored until you say otherwise
Caddy’s default here is safer than most and surprises people for exactly that reason.
reverse_proxy sets or augments X-Forwarded-For and sets
X-Forwarded-Proto and X-Forwarded-Host on the way to the backend.
But for values arriving at Caddy, it ignores them to prevent
spoofing unless trusted_proxies says the sender is trusted. With
no trusted_proxies, no proxy is trusted — so {client_ip} is the
address of the immediate peer and the incoming X-Forwarded-For is
not consulted.
| Symptom | Cause |
|---|---|
| Access log shows the real client address | Caddy is the outermost proxy — correct, nothing to do |
| Access log shows one constant address (a CDN or an LB) | trusted_proxies not configured for that upstream |
| Access log shows an address a client can choose | trusted_proxies too broad, or left-to-right parsing |
trusted_proxies static private_ranges is the shortcut for the RFC
1918 ranges plus loopback, and it is the right answer when Caddy sits
behind another proxy on the same host or the same private network. Add
trusted_proxies_strict whenever the thing in front appends to
X-Forwarded-For — which HAProxy, nginx, ALB and CloudFront all do —
so the chain is parsed right-to-left and the leftmost, client-supplied
entry cannot win. The client-identity lesson later in this part covers
why that direction matters.
The timeouts Caddy leaves off
Caddy’s defaults are modern in most places and deliberately absent in one that matters:
| Option | Default | Consequence of the default |
|---|---|---|
dial_timeout | 3s | fine |
response_header_timeout | no timeout | a backend that accepts the connection and never answers holds the request indefinitely |
read_timeout / write_timeout | no timeout | same, for the body |
lb_policy | random | fine |
lb_try_duration | 0 — retries disabled | a single failed dial is a 502, with no retry against a healthy peer |
health_interval | 30s | only applies once health_uri is set |
health_timeout | 5s | |
health_fails | 1 | one failed probe ejects the upstream |
The pairing to think about is response_header_timeout against your
application’s own request timeout. Leaving Caddy’s unset means Caddy
outlives every application-side deadline, so a request the application
abandoned is still occupying a Caddy connection. Set it slightly above
the application’s own limit and the two agree about when to give up.
lb_try_duration is the one worth adding even with a single upstream.
Enabling retries turns the redeploy window — the second or two where
the old container is gone and the new one is not listening yet — from
a burst of 502s into a slightly slower request.
Health checks, active and passive
Caddy does both, and neither is on by default.
- Active starts the moment you set
health_uri. Caddy polls onhealth_interval(30s), requireshealth_status(200) withinhealth_timeout(5s), and ejects afterhealth_fails(1) failures.health_fails 1is twitchy: one slow probe removes a healthy backend. Raise it to 2 or 3 for anything with variable latency. - Passive starts the moment you set
fail_duration, which defaults to0meaning off.max_failsfailures within that window eject the upstream forfail_duration.unhealthy_latencyalso counts a slow response as a failure, which is a genuinely useful thing the other proxies in this part make harder.
Use both. Active tells you a backend is dead before a user does;
passive catches the failure modes an idle /healthz probe cannot see,
such as one upstream in a pool degrading under real load.
TLS to the backend
If the backend terminates its own TLS, https:// on the upstream is
enough — and then the certificate has to actually validate.
Reloading and validating
Caddy has an admin API on localhost:2019 inside the container, and
caddy reload uses it. A reload is graceful: the new configuration is
loaded and validated before the old one is retired, so a broken file
leaves the running server untouched.
CADDYFILE=/etc/caddy/Caddyfile
# Parses and checks the config without touching the running server.
docker compose exec -T caddy caddy validate --config "$CADDYFILE"
# Normalise formatting; --overwrite edits in place.
docker compose exec -T caddy caddy fmt --overwrite "$CADDYFILE"
# Apply. Zero-downtime; fails safe if the new config is invalid.
docker compose exec -T caddy caddy reload --config "$CADDYFILE"Verification that can fail
$ echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | openssl x509 -noout -issuer -subject -datesissuer=C = US, O = Let's Encrypt, CN = R13
subject=CN = app.example.com
notBefore=Aug 2 09:14:22 2026 GMT
notAfter=Oct 31 09:14:21 2026 GMTIllustrative output
An issuer containing (STAGING) means the staging CA is still
configured. An issuer of Caddy Local Authority means Caddy decided
the name was local — usually because the site address is localhost
or an IP, or because local_certs is set — and no public certificate
was ever ordered.
HOST=app.example.com
BACKEND=web
# 1. Configuration is valid. Non-zero exit is the answer.
docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile
# 2. Certificates exist on the persisted volume, not just in memory.
docker compose exec -T caddy find /data/caddy/certificates -name '*.crt' | head
# 3. Caddy can reach the backend on the port the Caddyfile names.
docker compose exec -T caddy wget -qO- --timeout=3 "http://$BACKEND:8080/healthz" && echo REACHABLE || echo UNREACHABLE
# 4. Is HTTP/3 actually offered? Absent alt-svc means the UDP port is not published.
curl -sSI "https://$HOST/" | grep -i '^alt-svc' || echo 'no HTTP/3 advertised'Check 2 is the one that catches the rate-limit trap before it costs
you a week: if /data/caddy/certificates is empty on a running,
working site, the certificate is not being persisted and the next
recreate will order a new one.
Choosing between the four
| Caddy | nginx | HAProxy | Traefik | |
|---|---|---|---|---|
| Certificates | automatic, built in | external (certbot) | external, and concatenated | automatic, built in |
| Discovery | static file, DNS at dial time | static file | DNS via resolvers | Docker labels |
| Active health checks | yes | commercial only | yes, with fastinter | yes |
| Runtime state change | admin API | no | Runtime API | n/a |
| L4 / TCP | plugin | stream module | first-class | limited |
| Serves static files | yes | yes | no | no |
| Config readable by a newcomer | yes | mostly | no | scattered across labels |
| Needs the Docker socket | no | no | no | yes |
Caddy is the right default for a small-to-medium deployment with a stable backend set and public hostnames: it is the shortest path from nothing to correct HTTPS, and the Caddyfile is the only one of these four a colleague can read without training. Choose against it when you need L4 load balancing, when you need to drain a backend without touching configuration, or when the backend set changes on every deploy and you would rather the proxy discovered it.
Knowledge check
Knowledge check · 4 questions
Q1. Which Caddyfile site address results in Caddy obtaining a publicly trusted certificate?
Q2. After several `docker compose down -v` cycles during development, Caddy stops getting certificates and the logs show rate-limit errors. What actually happened?
Q3. Which of these are true of a default Caddy reverse_proxy in front of a container? Select all that apply.
Q4. With no `trusted_proxies` configured, Caddy ignores an incoming X-Forwarded-For header rather than believing it.
Passing score: 75%. Answers are checked in this browser.
Where next
All four proxies replace the client’s address with their own, and the header that is supposed to fix that is attacker-controlled by default. The next lesson is about getting the real client address back safely in each of them.