nginx reverse proxy with TLS (2026)
server blocks: one on port 80 that 301-redirects everything to
HTTPS, and one on port 443 that terminates TLS and proxy_passes to your app's
upstream. Forward Host and the X-Forwarded-* headers so the app sees
the real client, and restrict TLS to 1.2 and 1.3. Copy-paste config below.
The config
upstream app {
# Your app — same host, container, or LAN. Keep it on localhost/private net.
server 127.0.0.1:8000;
}
# HTTP → HTTPS: redirect everything
server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://$host$request_uri;
}
# HTTPS: terminate TLS, proxy to the app
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern TLS only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
# Pass the real client + scheme to the app
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;
proxy_set_header X-Forwarded-Host $host;
# WebSocket / connection upgrade support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
}
Why each part matters
Two server blocks
Port 80 exists only to bounce plain HTTP up to HTTPS with a 301 — permanent, so
browsers and search engines remember it. All real traffic lands on the port-443 block, which is
the only place TLS is configured.
Forwarded headers
Behind a proxy your app no longer sees the client directly — it sees nginx. Host,
X-Real-IP, X-Forwarded-For and X-Forwarded-Proto hand the
real hostname, client IP, and original scheme through. Without X-Forwarded-Proto the
app thinks every request is plain HTTP and may build broken http:// redirects or
refuse secure cookies.
Modern TLS only
ssl_protocols TLSv1.2 TLSv1.3 drops SSLv3, TLS 1.0 and 1.1 — all deprecated and
failing PCI/security scans. With TLS 1.3 in the mix you let the client pick the cipher
(ssl_prefer_server_ciphers off), which is the current recommendation.
Connection upgrade
The Upgrade / Connection headers plus proxy_http_version 1.1
let WebSockets pass through. Omit them and your real-time connections silently fail to handshake.
Getting the certificate
The config above points at Let's Encrypt paths. Issue and auto-renew the cert with certbot:
sudo certbot --nginx -d example.com
# certbot installs a systemd timer that renews automatically
For containers, mount the certs in or use a companion ACME container — the nginx config itself is identical; only the certificate paths change.
Common mistakes
- Forgetting
X-Forwarded-Proto— the app buildshttp://redirect loops or rejects secure cookies because it thinks the request wasn't encrypted. - Proxying to a public address — keep the upstream on
localhost/a private network so traffic can't reach the app bypassing TLS. - Leaving old TLS versions on — TLS 1.0/1.1 fail modern scans; pin 1.2 + 1.3.
- No
proxy_set_header Host— name-based vhosts and absolute URLs in the app break because it sees nginx's default host.
Going further
For a complete, ready-to-run project — app, Dockerfile, docker-compose with Postgres, CI, and a
health endpoint already wired — the SysBuild Pro Pack ships six production
stacks (including fastapi-postgres and node-express-postgres, the exact
apps you'd put behind this proxy) for $29 one-time.