If you're self-hosting more than one thing — a Nextcloud instance, a Grafana dashboard, a couple of side projects — you eventually hit the same wall: everythin…
If you're self-hosting more than one thing — a Nextcloud instance, a Grafana dashboard, a couple of side projects — you eventually hit the same wall: everything wants port 80 or 443, and manually wrangling Nginx configs plus certbot renewals for each service gets old fast. Caddy fixes this properly. It gets you automatic HTTPS, clean config syntax, and zero manual certificate babysitting, and it takes about twenty minutes to set up right.
This isn't a toy setup either — Caddy is genuinely production-capable and is what a lot of small-to-mid infrastructure runs behind these days, precisely because it removes an entire category of "why did my cert expire" 3am pages.
What you'll need
- A Linux server (Ubuntu 22.04/24.04 assumed here, but any modern distro works)
- A domain name you control, with DNS access
- Docker and Docker Compose installed (v2 syntax,
docker compose, not docker-compose)
- Ports 80 and 443 open on the server's firewall and not already bound by something else (check with
sudo ss -tulpn | grep -E ':80|:443')
- At least one backend service already running that you want to expose (we'll use a simple example app, but swap in whatever you've actually got)
- Root or sudo access
Step 1: Point your DNS at the server
Before touching Caddy, get your DNS sorted — it needs to resolve before Caddy tries to get a certificate, or the ACME challenge will fail.
Create an A record (and AAAA if you have IPv6) pointing your subdomain at the server's public IP:
app.yourdomain.com. A 203.0.113.42
Give it a few minutes to propagate. Confirm with:
dig +short app.yourdomain.com
If that doesn't return your server's IP, stop here and fix DNS first. Everything downstream depends on it.
Step 2: Set up the project structure
We'll run Caddy and your backend services in the same Docker Compose stack so they share a network.
mkdir -p ~/caddy-proxy && cd ~/caddy-proxy
mkdir data config
Step 3: Write the Compose file
# docker-compose.yml
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./data:/data
- ./config:/config
networks:
- proxy
whoami:
image: traefik/whoami
container_name: whoami
restart: unless-stopped
networks:
- proxy
networks:
proxy:
name: proxy
traefik/whoami here is just a lightweight test container — a real HTTP server that echoes request info, useful for confirming the proxy actually works before you point it at something real.
Step 4: Write the Caddyfile
This is where Caddy earns its reputation. Compare this to an equivalent Nginx + certbot setup and weep with relief:
app.yourdomain.com {
reverse_proxy whoami:80
}
That's it. Three lines. Caddy will:
- Detect it's a public domain
- Automatically request a certificate from Let's Encrypt via ACME
- Redirect HTTP to HTTPS
- Auto-renew before expiry, forever, without a cron job
For a real multi-service setup, you just stack blocks:
app.yourdomain.com {
reverse_proxy app-backend:3000
}
grafana.yourdomain.com {
reverse_proxy grafana:3000
}
status.yourdomain.com {
reverse_proxy uptime-kuma:3001
}
Each block gets its own cert, issued and renewed independently. No wildcard cert juggling required unless you specifically want one.
Step 5: Bring it up
docker compose up -d
docker compose logs -f caddy
Watch the logs — you should see Caddy obtain a certificate within a few seconds of startup:
INFO tls.obtain acquiring lock {"identifier": "app.yourdomain.com"}
INFO tls.obtain certificate obtained successfully
Then hit https://app.yourdomain.com in a browser. You should get a valid padlock and the whoami container's response, with your request headers echoed back.
Step 6: Add your real services
Add each app you want exposed as its own service in the compose file, put it on the proxy network, and add a matching block in the Caddyfile pointing at service_name:internal_port. Don't publish those services' ports to the host directly — let Caddy be the only thing touching 80/443, and let everything else talk over the internal Docker network. That's the whole security model in one sentence.
Where this actually goes wrong
Cert issuance silently fails and you don't notice. Usually DNS. If dig resolves fine from your laptop but Caddy still fails, check that nothing upstream (Cloudflare proxy, CDN, another firewall) is intercepting port 80 before ACME's HTTP-01 challenge can complete. If you're using Cloudflare, temporarily set the DNS record to "DNS only" (grey cloud) for the initial cert issuance, or switch to the DNS-01 challenge with the Cloudflare plugin if you want the orange cloud on permanently.
Port 80 is already taken. Apache's default install, an old Nginx you forgot about, or a previous Caddy instance that didn't shut down cleanly. sudo ss -tulpn | grep :80 tells you exactly what's squatting there.
"Rate limited" errors from Let's Encrypt. This happens when you're iterating on config and restarting Caddy repeatedly with a broken domain — each restart can trigger a new order attempt. Let's Encrypt's rate limits are generous but not infinite (50 certs per registered domain per week). Get DNS right before you start hammering docker compose up, and use Caddy's staging CA while testing:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Remove that global block once you've confirmed everything works, then restart to get a real, trusted cert.
Reverse proxy returns 502. Almost always a Docker networking issue — the backend container isn't actually on the proxy network, or you're proxying to the wrong internal port (the container's internal port, not whatever you mapped it to on the host — you don't need host mapping at all if Caddy's the one talking to it).
Websockets not working through the proxy. Caddy handles this automatically for reverse_proxy in v2 — if it's broken, it's almost always the backend app not handling the Upgrade header correctly, not Caddy.
A couple of best-practice notes
Keep the data volume around — it holds your certificates and Caddy's storage state. Deleting it means re-issuing every certificate from scratch, which is fine occasionally but avoid making it a habit given rate limits.
If you're exposing admin panels (Grafana, a database UI, anything sensitive), layer on Caddy's built-in basicauth directive or put it behind a VPN/Tailscale rather than relying on the app's own login page as your only line of defense. Defense in depth costs you three lines of config here.
Where to go from here
Once this is running comfortably, look into Caddy's on_demand_tls for dynamically provisioning certs for user-supplied domains (handy if you're building a multi-tenant SaaS), or pair this setup with Watchtower to auto-update your containers. The official Caddy docs are unusually good for a piece of infrastructure software — worth actually reading rather than just copy-pasting Caddyfiles from Stack Overflow.
Hashtags
#caddy #reverseproxy #selfhosting #dockercompose #letsencrypt #devops
Want this as a downloadable file instead?