KnowledgebaseNetworking & DNS › Reverse-proxy TLS termination patterns — nginx, Caddy, Traefik on a VPS

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's SECURE_PROXY_SSL_HEADER, Rails's config.force_ssl with 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 (--staging on certbot, or acme_ca https://acme-staging-v02.api.letsencrypt.org/directory in 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

« « Back

Powered by WHMCompleteSolution