Most "serve your app over HTTPS" tutorials still reach for Nginx plus certbot: hand-written reverse-proxy configs, a cron job for certificate renewal, and a debugging session for every acme-challenge failure. Caddy removes all of that. It obtains and renews TLS certificates automatically, and the entire setup fits in two lines of config.
In this tutorial you'll put a Dockerized application behind Caddy so that it's served over HTTPS with zero certificate management. No certbot, no renewal timers, no Nginx.
Prerequisites
- A Linux server with Docker and Docker Compose installed (tested on Ubuntu 22.04+).
- A domain name you control, with DNS access.
- Ports
80and443reachable from the internet (needed for the ACME HTTP-01 challenge).
Step 1 — Run your app on an internal port
You can use any containerized app. For this tutorial, a minimal example:
# docker-compose.yml
services:
app:
image: nginx:alpine # stand-in for your real application
The important part: your app must be reachable from the Caddy container, not from the internet. Either don't publish its ports at all, or bind them to localhost only. Caddy will be the only service exposed publicly.
If your real app listens on port 3000, leave it unexposed:
services:
app:
image: your-app:latest
# no `ports:` here — reachable only inside the compose network
Step 2 — Add Caddy to the stack
Add a caddy service to the same compose file:
services:
app:
image: your-app:latest
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
volumes:
caddy_data:
caddy_config:
Two things matter here:
-
caddy_datapersists TLS certificates across container rebuilds. Losing it forces re-issuance and counts against Let's Encrypt rate limits. - Port
443/udpenables HTTP/3; it's optional but costs nothing.
Now the Caddyfile. Create Caddyfile next to your compose file:
app.example.com {
reverse_proxy app:3000
}
Replace app.example.com with your domain and app:3000 with your app's service name and port. That's the entire configuration. Caddy reads the site address, sees it's a real domain (not localhost), and automatically switches on TLS: it will solve the ACME HTTP-01 challenge, obtain the certificate from Let's Encrypt, store it in /data, and renew it in the background whenever it nears expiry.
Step 3 — Point DNS and open the firewall
Create an A record for your domain pointing at the server's public IP. If you run ufw, allow the web ports:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
Step 4 — Start the stack and verify
docker compose up -d
Watch Caddy obtain the certificate on first run:
docker compose logs -f caddy
You should see lines like:
certificate obtained successfully
certificate stored in storage
Then verify from any machine:
curl -I https://app.example.com
Expect HTTP/2 200 (or HTTP/3 from a browser). Your app is now served with a valid, automatically-renewed certificate.
Useful variations
Parameterize the domain. Pass it through the environment instead of editing the Caddyfile:
{$DOMAIN} {
reverse_proxy app:3000
}
caddy:
environment:
DOMAIN: app.example.com
Test against staging. If you're iterating and don't want to burn Let's Encrypt rate limits, point Caddy at the staging CA while you experiment:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
app.example.com {
reverse_proxy app:3000
}
Remove the acme_ca line for production.
Conclusion
Caddy turns "HTTPS for my container" from an ops project into two lines of config: one site block, one reverse_proxy. Certificates are issued on first start and renewed silently forever after, and the persistent /data volume keeps them safe across rebuilds. If you're starting a new Docker deployment, there's little reason to manage certificates by hand anymore.
Top comments (0)