Overview
Caddy is a modern reverse proxy that automatically provisions and renews TLS certificates via Let’s Encrypt. This guide shows how to put Caddy in front of containers using Docker Compose, terminating HTTPS with minimal configuration.
Quickstart
- You need a domain pointed to your server’s public IP (A/AAAA record), and ports 80 and 443 open.
- Create a Caddyfile mapping your domain to an internal service.
- Start the stack with Docker Compose.
- Caddy will obtain and renew certificates automatically.
Minimal working example
The example proxies example.com over HTTPS to an internal demo service.
Files:
- docker-compose.yml
- Caddyfile
docker-compose.yml:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
app:
image: nginxdemos/hello
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
Caddyfile:
{
email [email protected]
}
example.com {
encode gzip zstd
reverse_proxy app:80
}
Run:
docker compose up -d
Check logs:
docker compose logs -f caddy
Verify:
curl -I https://example.com
Step-by-step
- Prerequisites
- DNS: Point example.com to your server’s IPv4/IPv6.
- Firewall: Allow TCP/80, TCP/443, UDP/443.
- Docker/Compose installed.
- Create project
- Make a directory and add the docker-compose.yml and Caddyfile from the example.
- Bring it up
- Start the stack: docker compose up -d.
- Caddy will solve the HTTP-01 challenge over port 80 and store certs under /data.
- Confirm issuance
- docker compose logs caddy should show “certificate obtained” for example.com.
- Visit https://example.com and confirm a valid padlock.
- Persist and maintain
- The caddy_data volume persists certificates across restarts.
- Updates: docker compose pull && docker compose up -d.
What the files/paths do
| Item | Purpose |
|---|---|
| Caddyfile | Declarative site config and routes |
| /data (caddy_data) | ACME account, certs, OCSP cache (keep persistent) |
| /config (caddy_config) | Caddy runtime config/autosave; optional but useful to persist |
Local development and staging
- Staging ACME (avoid rate limits while testing):
{
email [email protected]
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
example.com {
reverse_proxy app:80
}
Switch back to production by removing acme_ca or pointing to the production directory.
- No public DNS (pure local dev): use Caddy’s internal CA:
{
email [email protected]
}
local.test {
tls internal
reverse_proxy app:80
}
You’ll need to trust Caddy’s internal root CA on your machine to avoid browser warnings.
Multiple sites and services
You can add more site blocks pointing to different services:
site1.example.com {
reverse_proxy app:80
}
api.example.com {
reverse_proxy api:8080
}
Then define an api service in docker-compose.yml and connect it to the same network (default is fine).
Managing and reloading config
- Change Caddyfile, then reload without downtime:
docker exec -it $(docker compose ps -q caddy) caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
- Or simply docker compose up -d after editing; Caddy will hot-reload.
Performance notes
- Enable HTTP/3 by publishing UDP/443 (already in the example). Browsers will negotiate QUIC automatically.
- Compression: encode gzip zstd is on in the example; zstd is faster at similar or better ratios for many payloads.
- Connection reuse: Caddy defaults to keep-alive and TLS session resumption. Leave /data persistent for fast restarts and OCSP caching.
- Resource sizing: Caddy is efficient; typical reverse proxy throughput scales with CPU. Monitor memory and CPU with docker stats.
- Logging: For high throughput, consider writing logs to stdout (default) and aggregate outside the container to avoid disk I/O bottlenecks.
Common pitfalls
- Port binding conflicts: Another service listening on 80/443 will block Caddy. Stop that service or change its ports.
- DNS propagation delay: Certificate issuance fails if DNS hasn’t propagated. Wait or verify with dig before starting.
- Proxied DNS/CDN: If a proxy sits in front of your server (e.g., orange-cloud DNS), HTTP-01 may fail. Either disable the proxy during issuance or use DNS-01 with a Caddy-DNS plugin (requires a custom Caddy image).
- IPv6 mismatch: If AAAA exists but v6 doesn’t reach your server, issuance can fail. Fix v6 routing or remove the AAAA record.
- Firewall/NAT: Ensure inbound 80/TCP is reachable; Let’s Encrypt needs it even if you only serve 443 to clients.
- Time drift: Large system clock skew breaks TLS handshakes and ACME. Sync with NTP.
- Volume permissions: On some hosts, restrictive mounts cause write failures. Keep caddy_data writable by the container.
Security notes
- HSTS: Consider adding Strict-Transport-Security once you’re confident HTTPS is stable. Test carefully; HSTS can lock clients to HTTPS.
- Minimal exposure: Only expose Caddy to the internet. Keep app services on the internal Docker network without published ports.
Tiny FAQ
How are renewals handled? Caddy renews automatically before expiry. No cron needed.
Can I use wildcard certificates? Yes, with DNS-01. Build Caddy with the appropriate DNS plugin and configure tls with the DNS provider credentials.
How do I serve multiple domains on one Caddy? Add additional site blocks in the Caddyfile. Caddy will obtain certs for each domain.
How do I rotate to a new Caddyfile safely? Use caddy reload (shown above). It validates before applying, minimizing downtime.
Where are my certificates stored? In the caddy_data volume under /data. Keep it persistent for seamless renewals.