Reverse-proxy TLS termination patterns — nginx, Caddy, Traefik on a VPS
You have an app listening on port 8080 (or 3000, or 5000) and you want it on https://app.example.com with a real cert. The pattern is a reverse proxy in front terminating TLS and forwarding to the app. This article covers the three common choices, their tradeoffs, and a working config for each.
What you're choosing between
- nginx — most-deployed, requires manual cert provisioning (or certbot's nginx plugin). Config syntax is verbose but battle-tested.
- Caddy — auto-TLS by default (Let's Encrypt happens transparently). Single binary, single config file, minimal ceremony. Default choice for personal projects.
- Traefik — designed for Docker / dynamic environments where backends come and go. Higher complexity, real wins when services move around.
For most single-VPS setups: Caddy. If you already know nginx, nginx. If you're running Docker Compose with services that scale up and down: Traefik.
Caddy: the simplest possible
apt install caddy # Debian/Ubuntu, after adding Caddy's official APT repo
# OR
dnf install caddy # AlmaLinux via EPEL
Edit /etc/caddy/Caddyfile:
app.example.com {
reverse_proxy localhost:8080
}
api.example.com {
reverse_proxy localhost:3000
}
systemctl reload caddy
That's the whole config. Caddy:
- Solves the Let's Encrypt ACME challenge automatically (HTTP-01 if port 80 is reachable, TLS-ALPN-01 otherwise).
- Renews certificates automatically before expiry.
- Adds modern TLS defaults (TLS 1.2+, secure cipher suites).
- Auto-redirects HTTP → HTTPS.
Add headers, basic auth, or backend health checks with one-line directives in the same block. Caddy's docs are notably good; the Caddyfile syntax is forgiving.
nginx: most-deployed, most documentation online
apt install nginx certbot python3-certbot-nginx # Debian/Ubuntu
dnf install nginx certbot python3-certbot-nginx # AlmaLinux
Drop a server block at /etc/nginx/sites-available/app.example.com (Debian) or /etc/nginx/conf.d/app.example.com.conf (Alma):
server {
listen 80;
listen [::]:80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app.example.com;
# Certbot adds these after running
# ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable + get the cert:
# Debian: link sites-available -> sites-enabled
ln -sf /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
# Test config
nginx -t
# Get cert (certbot auto-edits the config to add ssl_certificate lines)
certbot --nginx -d app.example.com
# Renewal is a cron/systemd-timer that's installed automatically
systemctl list-timers | grep certbot
WebSocket support
HTTP/1.1's Upgrade header doesn't pass through default reverse-proxy configs. For apps with WebSockets (Mastodon's streaming, FreeSWITCH ESL via WSS, any modern SPA with live updates):
nginx:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # long timeout for persistent connections
# ... other proxy_set_header lines as before
}
}
Caddy: WebSocket support is automatic; reverse_proxy Just Works.
Common gotchas
- Backend doesn't know it's behind a proxy. Set
X-Forwarded-*headers and tell the backend to trust them (Django'sSECURE_PROXY_SSL_HEADER, Rails'sconfig.force_sslwith trusted proxies, etc.). Otherwise the backend generates HTTP URLs in its responses. - Path rewriting. Some apps assume they're at the root path; if you reverse-proxy to
app.example.com/admin/, the app may generate links without the/admin/prefix. Either configure the app for sub-path mounting or use a subdomain. - HSTS preload + wrong cert. Once you set
Strict-Transport-Security: max-age=31536000; preload, browsers refuse to load the site with a self-signed or expired cert for a year. Don't enable HSTS preload until you're confident your cert renewal is solid. - Let's Encrypt rate limits. 50 certs per registered domain per week; testing with real LE causes rate-limit lockouts. Use the staging environment (
--stagingon certbot, oracme_ca https://acme-staging-v02.api.letsencrypt.org/directoryin Caddy) until your config is right, then switch to production.
What about Apache?
Apache reverse proxy works fine (mod_proxy, mod_ssl). It's less commonly the choice for new setups because nginx and Caddy do the same thing with less config. If you're already running Apache for a PHP app, adding reverse-proxy server blocks for other backends is reasonable; if you're starting fresh, nginx or Caddy.
Also Read
Powered by WHMCompleteSolution