You shouldn't be writing nginx configs in 2026. Caddy gets you a green padlock in three lines of config, renews the cert before it expires, and has reasonable defaults. Here's the full setup.
Prerequisites
- A VPS running Ubuntu / Debian (this guide uses Ubuntu 24.04)
- A domain pointing at the VPS's IPv4. A record propagated (check with
dig +short example.com) - Ports 80 and 443 open in the firewall
Install Caddy
The official Caddy repo provides apt packages with built-in plugin support:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \
sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \
sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
Caddy is now running as a systemd service. Confirm:
$ systemctl status caddy
β caddy.service - Caddy
Loaded: loaded (/lib/systemd/system/caddy.service; enabled)
Active: active (running)
Write the config
Caddy's config lives in /etc/caddy/Caddyfile. The simplest possible "give me HTTPS for example.com and reverse-proxy to my app on :3000" looks like this:
example.com {
reverse_proxy localhost:3000
}
That's it. Three lines. No certificate paths, no listen directives, no challenge config. Caddy will:
- Listen on 443 (TLS) and 80 (redirect to HTTPS).
- Solicit a Let's Encrypt certificate via HTTP-01 challenge.
- Renew the cert 30 days before expiry.
- Reverse-proxy every request to your Node / Python / Go app on port 3000.
Reload Caddy:
$ sudo systemctl reload caddy
First request to https://example.com will take a few seconds (cert provisioning); subsequent requests are instant.
Multiple sites
Add another site block:
example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:4000
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
}
static.example.com {
root * /var/www/static
file_server
}
Each block is independent. Reload Caddy and all three pick up certificates on their first request.
Real client IPs through the proxy
By default your app sees 127.0.0.1 as the client IP. Forward the real IP:
example.com {
reverse_proxy localhost:3000 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
}
}
Then read X-Real-IP in your app. Express, FastAPI, Fiber, etc. all have middleware that respects these headers (often via trust proxy setting).
Behind Cloudflare
If you want Cloudflare's CDN + DDoS in front:
- In Cloudflare DNS, set the A record to proxy mode (orange cloud).
- In Cloudflare SSL/TLS, set mode to "Full (strict)", this means Cloudflare validates Caddy's cert.
- In your Caddyfile, add:
example.com {
reverse_proxy localhost:3000
# Trust Cloudflare's edge IPs for real_ip purposes
@cf {
remote_ip 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 # ... full list
}
header X-Real-IP {http.request.header.CF-Connecting-IP}
}
Cloudflare's edge IP list is at cloudflare.com/ips. Keep it updated.
Troubleshooting
- "no such file or directory" on cert provisioning β port 80 isn't open or DNS isn't pointing at this VPS. Verify with
curl -v http://example.com(should hit your server) anddig +short example.com(should return the VPS IP). - Cert renewal failing β check
journalctl -u caddy -n 100. Most common cause: rate limit (Let's Encrypt has 50 certs per registered domain per week, easy to hit if you're restarting Caddy in a config-update loop). - 502 Bad Gateway β your app isn't listening on the port Caddy is reverse-proxying to. Test with
curl localhost:3000on the VPS.
Where to go next
- Security checklist for new VPS. UFW, fail2ban, SSH hardening before you put real traffic on the box.
- Caddy's full directive reference: caddyserver.com/docs.