What You'll End Up With
- Automatic HTTPS with Let's Encrypt — no certbot, no cron, no manual renewal
- Reverse proxy to any backend (Node, Python, Go, static files)
- HTTP→HTTPS redirect handled automatically
- Modern TLS 1.3 with sensible cipher defaults
- Graceful certificate renewal (Caddy handles this in the background)
- Rate limiting and compression ready to go
Why Caddy Instead of Nginx
| Feature | Caddy | Nginx + Certbot |
|---|---|---|
| HTTPS setup | Zero config | 30+ line config + certbot cron |
| Certificate renewal | Automatic, in-process | Cron job that sometimes fails |
| Config language | Human-readable | DSL with 200+ directives |
| HTTP/3 | Built-in | Requires compile from source |
| Compression | Auto, smart | Manual gzip config |
| Time to working HTTPS | 2 minutes | 30-60 minutes |
Prerequisites
- A VPS running Ubuntu 22.04+ (tested on Hetzner CX22)
- A domain name pointing to your VPS IP (DNS A record)
- Ports 80 and 443 open on your firewall
Step 1: Install Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
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 caddy
caddy version should show v2.7+.Step 2: Create Your Caddyfile
The Caddyfile is Caddy's configuration file. It's intentionally minimal:
# /etc/caddy/Caddyfile
# Simple reverse proxy to a local app
yourdomain.com {
reverse_proxy localhost:3000
# Optional: compression and caching
encode gzip
# Optional: security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
}
That's it. Save this file and Caddy will:
- Provision a Let's Encrypt certificate for
yourdomain.com - Set up HTTPS on port 443
- Redirect all HTTP traffic to HTTPS
- Proxy requests to your app on port 3000
- Renew the certificate automatically before it expires
Multiple domains
# /etc/caddy/Caddyfile — multi-site
app1.com {
reverse_proxy localhost:3000
}
app2.com {
reverse_proxy localhost:8080
}
api.yourdomain.com {
reverse_proxy localhost:8888
encode gzip
}
Static file server
# Serve static files (e.g. a React build)
yourdomain.com {
root * /var/www/html
file_server
encode gzip
}
Step 3: Open Firewall Ports
sudo ufw allow 80/tcp # HTTP (for Let's Encrypt challenges + redirect)
sudo ufw allow 443/tcp # HTTPS
sudo ufw reload
sudo ufw status shows 80 and 443 allowed.Step 4: Start Caddy
sudo systemctl restart caddy
Caddy will immediately try to provision certificates. This takes 10-30 seconds.
https://yourdomain.com in a browser. You should see a valid HTTPS connection with a padlock icon. If you see a certificate error, check sudo journalctl -u caddy -n 50 --no-pager.Common Setups
WebSocket proxy
yourdomain.com {
reverse_proxy localhost:3000 {
# WebSocket support is automatic in Caddy 2.x
# Just make sure your backend handles Upgrade headers
}
}
WebSocket support is built into Caddy's reverse proxy. No extra config needed.
API with path-based routing
yourdomain.com {
# Frontend
handle /app/* {
root * /var/www/html
file_server
}
# API
handle /api/* {
reverse_proxy localhost:8888
}
# Redirect root to /app
handle / {
redir /app/ permanent
}
}
Custom domain with wildcard cert
# Requires DNS-01 challenge (not covered in free version)
# See the full playbook for Cloudflare DNS setup
*.yourdomain.com {
reverse_proxy localhost:3000
}
Troubleshooting
"No certificate available" or infinite redirect
Caddy can't verify domain ownership. Check:
- DNS:
dig yourdomain.comreturns your VPS IP - Firewall: port 80 is open (Let's Encrypt uses HTTP-01 challenge)
- Caddy is running:
sudo systemctl status caddy
Certificate works but backend returns 502
Your backend isn't running on the port you specified. Check:
# Is your app listening?
sudo ss -tlnp | grep 3000
# Check Caddy logs for the exact error
sudo journalctl -u caddy -n 20 --no-pager
Rate limited by Let's Encrypt
You get 5 certificates per week per domain. If you're testing repeatedly:
# Use staging certificates during testing
# Add to your Caddyfile globally:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
# Remove this line before going live!
Caddy won't start
# Validate your Caddyfile
caddy validate --config /etc/caddy/Caddyfile
# Check for port conflicts
sudo ss -tlnp | grep -E ':80|:443'
Verification Checklist
| Check | Command | Expected |
|---|---|---|
| Caddy running | systemctl is-active caddy | active |
| HTTPS valid | Open in browser | Padlock icon, no warnings |
| HTTP→HTTPS redirect | curl -I http://yourdomain.com | 301 redirect to https |
| Backend proxied | curl -s https://yourdomain.com | Your app's response |
| Certificate auto-renew | caddy list-modules | grep tls | tls module loaded |
| Security headers | curl -sI https://yourdomain.com | grep X- | X-Content-Type-Options, X-Frame-Options |
Cost
| Item | Cost | Notes |
|---|---|---|
| Caddy | Free | Apache 2.0 license |
| Let's Encrypt certs | Free | Auto-renewed by Caddy |
| Domain | $10/yr | Any registrar |
| Total | ~$1/mo | Just the domain |