Nginx Reverse Proxy + TLS
Nginx is the workhorse we put in front of almost every web service: it terminates TLS, load-balances across upstream replicas, serves static assets, and forwards everything else to the application. In this guide we'll build a modern, secure reverse proxy configuration step by step — TLS 1.2/1.3, HSTS, OCSP stapling, WebSocket support, and the security headers that earn an A+ on observatory scans.
Real-World Scenario
SPA + API on subdomains, behind one Nginx box.
You host app.example.com (a single-page app) and api.example.com (a JSON API) on the same server. Both need real TLS certs, HTTP→HTTPS redirects, modern headers, WebSocket support for live updates, and a small upstream pool of API workers. We'll build that exact configuration by the end of this guide.
Server Blocks and proxy_pass
Nginx routes requests via server blocks. Each block matches on listen address/port and server_name. The simplest proxy looks like this:
The minimum viable reverse proxy
# /etc/nginx/conf.d/app.conf
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
That works but it's missing the headers the upstream app needs to behave correctly behind a proxy. Let's fix that.
The Four Headers You MUST Set
Without these, the upstream app sees the request as coming from Nginx (127.0.0.1) on http://, with no idea who the real client is. This breaks logging, rate limiting, CSRF, redirects, and anything that does request.scheme or request.host.
Host— preserve the original hostname so the upstream's virtual hosting and absolute URL generation work.X-Real-IP— the client's IP, single value.X-Forwarded-For— append the client IP to any existing chain (useful when there are multiple proxies).X-Forwarded-Proto—httpsorhttpso the app can build correct absolute URLs and set thesecureflag on cookies.
The full proxy header block (factor into a snippet)
# /etc/nginx/snippets/proxy_headers.conf 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; # Sensible timeouts (defaults are 60s) proxy_connect_timeout 5s; proxy_send_timeout 60s; proxy_read_timeout 60s; # HTTP/1.1 to support keepalive and WebSocket upgrades proxy_http_version 1.1;
Then include it in any location that proxies:
location / {
include /etc/nginx/snippets/proxy_headers.conf;
proxy_pass http://127.0.0.1:3000;
}
Upstream Blocks & Load Balancing
To put multiple backends behind one Nginx, declare an upstream. Nginx will distribute requests across them.
Upstream patterns
# Round-robin (default) — distributes requests evenly
upstream api_backend {
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000;
}
# Least connections — best for variable-cost requests
upstream api_backend {
least_conn;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
}
# IP hash — same client always lands on the same backend
# (use when your app stores session state in-process)
upstream api_backend {
ip_hash;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
}
# Health/backup helpers
upstream api_backend {
server 10.0.0.11:3000 max_fails=3 fail_timeout=30s;
server 10.0.0.12:3000;
server 10.0.0.99:3000 backup; # only used if others are down
}
server {
listen 80;
server_name api.example.com;
location / {
include /etc/nginx/snippets/proxy_headers.conf;
proxy_pass http://api_backend;
}
}
Tip: for modern stateless apps, prefer least_conn. Use ip_hash only when you genuinely have in-process session state — it interacts badly with autoscaling because adding/removing a backend reshuffles every client.
TLS Termination (Modern Config)
This is the part where many guides go wrong. The recommendations below match the Mozilla "Intermediate" profile as of mid-2020s: TLS 1.2 and 1.3 only, modern cipher list, server cipher preference disabled (TLS 1.3 ignores it anyway, and for TLS 1.2 modern clients pick fine).
Modern TLS settings (drop into a snippet)
# /etc/nginx/snippets/tls_modern.conf ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; # TLS 1.3 ignores server cipher order; leave it off so TLS 1.2 also follows client preference ssl_prefer_server_ciphers off; # Session resumption (perf) ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; # OCSP stapling — see next section ssl_stapling on; ssl_stapling_verify on; resolver 1.1.1.1 9.9.9.9 valid=300s; resolver_timeout 5s;
Bind it to a server block:
server {
listen 443 ssl;
http2 on; # nginx 1.25+: prefer this over `listen 443 ssl http2`
server_name app.example.com;
include /etc/nginx/snippets/tls_modern.conf;
# ...location blocks...
}
HTTP → HTTPS Redirect
A dedicated port-80 server block that 301-redirects everything to HTTPS. It also leaves room for the ACME challenge path that Let's Encrypt uses.
Redirect block
server {
listen 80;
server_name app.example.com api.example.com;
# Let certbot's http-01 challenges through (no redirect)
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
# Everything else gets redirected
location / {
return 301 https://$host$request_uri;
}
}
HSTS — HTTP Strict Transport Security
HSTS tells browsers "never speak plain HTTP to this domain again, for the next N seconds." Once a browser sees this header, it will refuse to fall back to HTTP — protecting against downgrade attacks.
HSTS header (RFC 6797)
# Inside the HTTPS server block, after include of tls_modern.conf add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
Warning: once you set HSTS on a domain, browsers will lock onto HTTPS for max-age seconds. Don't roll it out until you're certain HTTPS works on every subdomain you cover with includeSubDomains. Start with a smaller max-age (e.g. 300) when testing, then ratchet up to 2 years (63072000). Only add ; preload after you've intentionally submitted to the HSTS preload list.
OCSP Stapling
OCSP (RFC 6960) is how clients check whether a cert has been revoked. By default the browser contacts the CA's OCSP responder, which is slow and leaks browsing data. With stapling, your server fetches a signed OCSP response itself and "staples" it onto the TLS handshake — faster and more private.
The directives in our tls_modern.conf handle this:
ssl_stapling on;— enable stapling.ssl_stapling_verify on;— verify the OCSP response signature.resolver 1.1.1.1 9.9.9.9 valid=300s;— Nginx needs a DNS resolver to look up the OCSP responder URL; without this, stapling silently fails.
Verify stapling is working after a reload:
echo | openssl s_client -connect app.example.com:443 -servername app.example.com -status 2>/dev/null | grep -E 'OCSP response:|Cert Status' # You want to see: "OCSP Response Status: successful" and "Cert Status: good"
Let's Encrypt with certbot
Certbot's Nginx plugin reads your config, issues a cert via the ACME http-01 challenge, and edits the config in place to enable HTTPS. The simplest invocation:
Issue and auto-configure
sudo apt install certbot python3-certbot-nginx # Ubuntu/Debian # or: sudo dnf install certbot python3-certbot-nginx # RHEL/Rocky/Fedora # Issue cert and let certbot edit nginx config sudo certbot --nginx -d app.example.com -d api.example.com # Renewal happens automatically via systemd timer (certbot.timer) on most distros. # Force a renewal dry-run to verify the setup: sudo certbot renew --dry-run
http-01 vs dns-01
Two ACME challenge types:
- http-01 — Let's Encrypt connects to
http://yourdomain/.well-known/acme-challenge/<token>on port 80. Requires that port 80 be reachable from the internet. Cannot issue wildcard certs. - dns-01 — you (or a plugin) create a TXT record under
_acme-challenge.yourdomain. Works behind firewalls and is the only way to get a wildcard cert (*.example.com). Requires API access to your DNS provider.
Use http-01 by default. Reach for dns-01 when you need wildcards or your servers don't expose port 80.
WebSocket Proxying
WebSockets use an HTTP/1.1 upgrade handshake. Nginx needs explicit Upgrade and Connection headers to pass it through. The cleanest pattern uses a map so the same config also works for non-upgrading requests.
WebSocket-ready proxy
# At the http {} (or global) level — typically in nginx.conf or a conf.d/ file
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
include /etc/nginx/snippets/tls_modern.conf;
location / {
include /etc/nginx/snippets/proxy_headers.conf;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# WebSocket connections can sit idle; bump read timeout
proxy_read_timeout 3600s;
}
}
Compression — gzip and brotli
gzip ships with Nginx; brotli typically requires the ngx_brotli module (available in nginx-extras on Ubuntu, or via custom builds).
gzip basics
gzip on;
gzip_vary on; # send Vary: Accept-Encoding so caches behave
gzip_proxied any;
gzip_min_length 1024; # don't bother for tiny responses
gzip_comp_level 5; # 1-9; 5 is a good cost/ratio balance
gzip_types
text/plain text/css text/xml text/javascript application/javascript
application/json application/xml application/xml+rss application/atom+xml
image/svg+xml font/woff font/woff2;
# Note: image/jpeg, png, webp, etc. are already compressed — leave them out.
# If you have brotli compiled in:
brotli on;
brotli_comp_level 5;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;
Security Headers
Nginx can set the cheap, high-value headers most apps forget about.
Security header block
# Inside the HTTPS server block add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; # or SAMEORIGIN add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; # Content-Security-Policy is highly app-specific. Start strict, then loosen as needed. # Example for a vanilla server-rendered site: add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" always;
The always flag matters: without it, Nginx only adds the header on 2xx/3xx responses. Error pages would skip it — which is exactly when an attacker probing for misconfigurations cares.
Use the CSP Evaluator and Mozilla Observatory to tune CSP for your specific app.
Worked Example: SPA + API on Subdomains
Putting it all together. app.example.com serves a static SPA from disk and forwards /api calls (and WebSocket) to a pool of API workers on api.example.com.
/etc/nginx/conf.d/example.com.conf
# Shared map for websocket upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Upstream pool of API workers
upstream api_backend {
least_conn;
server 10.0.0.11:3000 max_fails=3 fail_timeout=30s;
server 10.0.0.12:3000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
# ---------- HTTP → HTTPS redirect ----------
server {
listen 80;
server_name app.example.com api.example.com;
location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
# ---------- SPA frontend ----------
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
include /etc/nginx/snippets/tls_modern.conf;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /var/www/spa;
index index.html;
# Long-cache fingerprinted assets
location ~* \.(?:css|js|woff2?|svg|png|jpg|jpeg|webp|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# SPA history routing — fall back to index.html for unknown paths
location / {
try_files $uri $uri/ /index.html;
}
}
# ---------- API backend (with WebSocket) ----------
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
include /etc/nginx/snippets/tls_modern.conf;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
location / {
include /etc/nginx/snippets/proxy_headers.conf;
proxy_pass http://api_backend;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
Testing & Reloading
Always test before reloading. nginx -t validates syntax and that referenced files exist.
Safe reload workflow
# Test config sudo nginx -t # Reload (graceful, doesn't drop existing connections) sudo systemctl reload nginx # or: sudo nginx -s reload # View the cert + protocol in use echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates # Lint headers from outside curl -sI https://app.example.com | sed -n '1,/^$/p'
Then run an external audit: SSL Labs for the TLS config and Mozilla Observatory for the headers.
Summary
- ✓ Use
server+location+proxy_passfor the basics. - ✓ Always set Host / X-Real-IP / X-Forwarded-For / X-Forwarded-Proto.
- ✓ Use
upstreamwithleast_connfor load balancing;ip_hashonly when stateful. - ✓ TLS 1.2 + 1.3 only, Mozilla intermediate ciphers,
ssl_prefer_server_ciphers off. - ✓ HSTS with a long
max-ageonce you're confident in HTTPS coverage. - ✓ OCSP stapling — and don't forget the
resolverdirective. - ✓ Let's Encrypt via certbot's nginx plugin; auto-renews via systemd timer.
- ✓ WebSockets need
Upgrade/Connectionheaders and HTTP/1.1. - ✓ Security headers — always remember the
alwaysflag.
An Nginx box configured this way passes SSL Labs A+ and Mozilla Observatory A, with sane upstream load balancing and WebSocket support. From here you can layer on rate limiting (limit_req), client cert auth (ssl_client_certificate), or HTTP/3 once it stabilizes in your build.