An Nginx Reverse Proxy With TLS, Done Properly
Introduction¶
Putting Nginx in front of an application server (Gunicorn, Node, a Docker
container) is one of the most common sysadmin tasks, and one of the easiest to
do 80% right and 20% wrong — a missing X-Forwarded-Proto, no WebSocket
upgrade, default timeouts that kill long requests. This is a config you can
adapt with confidence, plus the reasoning behind each block.
Concepts¶
A reverse proxy terminates the client connection, then opens its own connection to the backend. Two things the backend needs to know that it can't see any more:
- The real client IP — otherwise every request looks like it came from
127.0.0.1. Nginx passes it inX-Forwarded-For/X-Real-IP. - That the original request was HTTPS — so the app builds
https://URLs and setsSecurecookies. Passed inX-Forwarded-Proto.
The backend framework then needs to be told to trust those headers (Django's
SECURE_PROXY_SSL_HEADER, Express's trust proxy, etc.) — otherwise a client
can spoof them.
Hands-on¶
The config¶
# /etc/nginx/conf.d/app.conf
upstream app_backend {
server 127.0.0.1:8000;
keepalive 32; # reuse backend connections
}
# Redirect all HTTP to HTTPS.
server {
listen 80;
listen [::]:80;
server_name app.example.com;
# Let certbot answer the ACME challenge over HTTP.
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 308 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 25m; # match your app's upload limit
location / {
proxy_pass http://app_backend;
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 / SSE upgrade.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 5s;
proxy_read_timeout 60s; # raise for long-poll / streaming
proxy_send_timeout 60s;
proxy_buffering on;
}
}
Add this once, in http context (/etc/nginx/conf.d/upgrade.conf):
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Why these choices:
return 308not301— 308 preserves the request method, so a POST tohttp://isn't silently downgraded to GET.proxy_set_header Host $host— without it the backend seesHost: app_backendand virtual-host routing / absolute URLs break.$proxy_add_x_forwarded_forappends$remote_addrto any existing header, preserving a chain if there's a CDN in front.keepalivein the upstream needsproxy_http_version 1.1and an emptyConnectionheader (themaphandles both WebSocket and keepalive).http2 on;is the modern directive;listen ... ssl http2still works but is deprecated in Nginx 1.25+.
TLS with Let's Encrypt¶
sudo certbot certonly --webroot -w /var/www/certbot -d app.example.com
sudo nginx -t && sudo systemctl reload nginx
Renewal is a systemd timer installed by the certbot package:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run # confirm it will work
Add a deploy hook so Nginx picks up the new cert:
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh containing
#!/bin/sh and systemctl reload nginx.
Verification and troubleshooting¶
nginx -t # syntax + config test
curl -sI http://app.example.com # expect 308 to https
curl -sI https://app.example.com # expect 200 and HSTS header
curl -s https://app.example.com/whoami # app should log your real IP
Common problems:
- App redirects to
http://in a loop — the backend doesn't trustX-Forwarded-Proto. Enable proxy-header trust in the framework. 413 Request Entity Too Large—client_max_body_size(default 1 MB) is below the upload size. Set it on both theserverand the backend.504 Gateway Time-outon long requests — raiseproxy_read_timeout. The default is 60 s.- WebSockets connect then drop after ~60 s — same timeout, plus make sure
the
Upgrade/Connectionheaders andproxy_http_version 1.1are present. SSL_ERROR/ cert not trusted — you referencedcert.peminstead offullchain.pem; clients need the intermediate. Verify the chain with the SSL/TLS checker or the Certificate Decoder.- Real IP is always
127.0.0.1in logs — you're behind another proxy (CDN, cloud LB). Addset_real_ip_from <lb-cidr>;andreal_ip_header X-Forwarded-For;. nginx -tOK but reload does nothing — you edited a file that isn'tincluded. Checknginx -T(capital T) for the effective merged config.
Related tools and reading¶
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Web & Mail Servers
Running BIND 9 as an Authoritative Server
BIND as an authoritative server: zone files, secured AXFR with TSIG, SOA serial rules, and DNSSEC inline-signing. Here's a working master/slave setup.
October 24, 2026Varnish Cache in Front of a Site
Varnish is a caching HTTP accelerator configured in VCL. Here's the request lifecycle, why you strip cookies, how bans work, and grace mode for serving stale during backend trouble.
October 24, 2026Let's Encrypt with Certbot, Including Renewal That Doesn't Break
Getting the first cert is easy; keeping renewal working for years is the part people get wrong. Webroot vs DNS-01, deploy hooks, and the failure modes.
October 24, 2026