Caddy as a reverse proxy: simpler than Nginx, automatic HTTPS
How to replace Nginx with Caddy for self-hosted services with automatic HTTPS and 10-line config. Practical guide with Docker Compose.
Published: June 3, 2025
Nginx is great, but every time you add a service you write another config block, remember to reload certbot, and manually fix the certificate if something breaks. Caddy solves all of this by default: HTTPS with Let’s Encrypt is automatic, the Caddyfile is readable, and 10 lines genuinely cover three services on separate domains.
Why Caddy instead of Nginx
Nginx config is verbose and TLS requires certbot as a separate process with a cron job for renewal. Caddy handles everything internally: it fetches the certificate on first boot, renews it automatically, and if the domain isn’t propagated yet it retries in the background without crashing.
For a self-hosted server running 5–10 services, Caddy cuts your config from 150 lines to 30. It’s not a performance question — Nginx and Caddy are equivalent on normal traffic — it’s a maintenance question.
Basic Caddyfile
A Caddyfile that exposes three local services over HTTPS:
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8000
}
monitor.example.com {
reverse_proxy localhost:9000
}
That’s it. Caddy contacts Let’s Encrypt, obtains three certificates, and renews them automatically. You never touch it again.
Docker Compose with Caddy as gateway
In practice your services are already running in containers. Here’s a complete docker-compose.yml with Caddy in front of two services:
services:
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
open-webui:
image: ghcr.io/open-webui/open-webui:main
networks:
- proxy
portainer:
image: portainer/portainer-ce:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
- proxy
networks:
proxy:
volumes:
caddy_data:
caddy_config:
The matching Caddyfile uses Docker service names as hostnames (Caddy and the containers share the same network):
chat.example.com {
reverse_proxy open-webui:8080
}
portainer.example.com {
reverse_proxy portainer:9000
}
Wildcard certificate with Cloudflare
If you want a single wildcard cert for *.example.com instead of one per domain, you need the DNS challenge. This only works if your DNS is on Cloudflare (or another supported provider).
Build Caddy with the DNS plugin using the caddy:2-builder image:
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
In the Caddyfile:
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@webui host chat.example.com
handle @webui {
reverse_proxy open-webui:8080
}
}
Pass the Cloudflare token as an environment variable in docker-compose.yml. Port 80 doesn’t need to be open for the DNS challenge.
What to do
- Start Caddy with
docker compose up -d, point a subdomain at your IP, and watch the logs withdocker compose logs caddyto see the certificate issued in real time - Migrate from Nginx one service at a time: run Caddy on an alternate port (e.g. 8443) to test before switching live traffic
- If you use Cloudflare, enable the DNS challenge and consolidate everything under a wildcard
*.yourdomain.comto avoid managing individual certificates for every new service