Caddy is a web server that obtains and renews HTTPS certificates automatically — no Certbot, no cron. Its config file, the Caddyfile, is so short that a reverse proxy for a container is two lines. Running Caddy itself in Docker alongside your apps is a clean way to publish many services on one VPS.
1. Shared network
docker network create web
Every app that Caddy should proxy joins this network; apps don’t publish ports.
2. Caddy’s Compose file
mkdir -p ~/caddy && cd ~/caddy
compose.yaml:
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks: [web]
volumes:
caddy_data:
caddy_config:
networks:
web:
external: true
The caddy_data volume stores certificates and keys — keep it (don’t recreate it on every deploy), otherwise Caddy requests new certificates each time and can hit Let’s Encrypt rate limits.
3. The Caddyfile
{
email [email protected]
}
app.example.com {
reverse_proxy app:3000
}
uptime.example.com {
reverse_proxy uptime-kuma:3001
}
files.example.com {
reverse_proxy filebrowser:80
encode zstd gzip
}
app:3000 is the container (service) name and its internal port on the shared web network. That’s the whole configuration: Caddy redirects HTTP to HTTPS, obtains certificates, renews them, enables HTTP/2 and HTTP/3, and forwards the right headers (X-Forwarded-For, X-Forwarded-Proto, Host). WebSockets work automatically.
docker compose up -d
docker compose logs -f caddy
DNS for each hostname must point to the server before Caddy can get certificates.
4. An app joining the network
# ~/apps/uptime-kuma/compose.yaml
services:
uptime-kuma:
image: louislam/uptime-kuma:1
restart: unless-stopped
volumes:
- ./data:/app/data
networks: [web]
networks:
web:
external: true
No ports: — the app is only reachable through Caddy.
5. Reload after editing the Caddyfile
docker compose exec -w /etc/caddy caddy caddy reload
Zero downtime; if the new config is invalid, Caddy keeps the old one and prints the error. Format and validate:
docker compose exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile # needs a writable mount
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
Useful directives
Basic auth for an admin tool:
docker compose exec caddy caddy hash-password --plaintext 'secret'
admin.example.com {
basic_auth {
alice $2a$14$…hash…
}
reverse_proxy portainer:9000
}
IP restriction:
internal.example.com {
@blocked not remote_ip 203.0.113.0/24
respond @blocked 403
reverse_proxy grafana:3000
}
Security headers:
(security) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
app.example.com {
import security
reverse_proxy app:3000
}
Static files alongside:
example.com {
root * /srv/site
file_server
}
(Mount /srv/site into the Caddy container.)
Upload size / timeouts — Caddy has no default body-size limit; add request_body { max_size 100MB } if you want one.
Wildcard certificates
Wildcard certs need a DNS provider module — use a custom Caddy build (the caddy:builder image with xcaddy build --with github.com/caddy-dns/cloudflare) and the tls { dns cloudflare {env.CF_API_TOKEN} } directive.
Logs
app.example.com {
log {
output file /data/logs/app.log
format json
}
reverse_proxy app:3000
}
Caddy vs Traefik vs Nginx Proxy Manager
- Caddy — one readable config file, automatic HTTPS, very little to learn.
- Traefik — configuration via container labels; good when apps change often.
- Nginx Proxy Manager — web UI.
Prefer Caddy without Docker? See Caddy as a reverse proxy. A Docker VPS with Caddy in front can host many self-hosted apps on a single IP.