Skip to main content
RunBook Academy

Docker & ContainersXXIV · Reverse Proxies & TLSCaddy

Caddy — automatic HTTPS by default

Intermediate⏱ ~24 mindocker

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

Not yet marked complete on this device.

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 addressWhat Caddy does
app.example.compublic ACME certificate, HTTPS on 443, redirect from 80
localhost, *.localhost, 127.0.0.1, [::1]certificate from Caddy’s own local CA
:8080plain HTTP — a port with no hostname has no name to certify
http://app.example.complain HTTP — the explicit scheme opts out
app.example.com:8443ACME 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

Configuration change/etc/caddy/Caddyfile
{
# 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
}
}
Configuration changecompose.yaml
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:

Read-only / Safefind the data directory, then prove it is persisted
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:

Configuration changeglobal options while iterating
{
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.

SymptomCause
Access log shows the real client addressCaddy 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 choosetrusted_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:

OptionDefaultConsequence of the default
dial_timeout3sfine
response_header_timeoutno timeouta backend that accepts the connection and never answers holds the request indefinitely
read_timeout / write_timeoutno timeoutsame, for the body
lb_policyrandomfine
lb_try_duration0 — retries disableda single failed dial is a 502, with no retry against a healthy peer
health_interval30sonly applies once health_uri is set
health_timeout5s
health_fails1one 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 on health_interval (30s), requires health_status (200) within health_timeout (5s), and ejects after health_fails (1) failures. health_fails 1 is 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 to 0 meaning off. max_fails failures within that window eject the upstream for fail_duration. unhealthy_latency also 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.

Service impact possiblevalidate, then reload
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

Read-only / Safewhich CA actually issued this
$ echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | openssl x509 -noout -issuer -subject -dates
issuer=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 GMT

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

Read-only / Safefour checks that can fail
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

CaddynginxHAProxyTraefik
Certificatesautomatic, built inexternal (certbot)external, and concatenatedautomatic, built in
Discoverystatic file, DNS at dial timestatic fileDNS via resolversDocker labels
Active health checksyescommercial onlyyes, with fastinteryes
Runtime state changeadmin APInoRuntime APIn/a
L4 / TCPpluginstream modulefirst-classlimited
Serves static filesyesyesnono
Config readable by a newcomeryesmostlynoscattered across labels
Needs the Docker socketnononoyes

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

  1. Q1. Which Caddyfile site address results in Caddy obtaining a publicly trusted certificate?

  2. Q2. After several `docker compose down -v` cycles during development, Caddy stops getting certificates and the logs show rate-limit errors. What actually happened?

  3. Q3. Which of these are true of a default Caddy reverse_proxy in front of a container? Select all that apply.

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