I’ve configured Nginx as a reverse proxy dozens of times, and while the basic setup is always similar, the details — SSL termination, certificate management, header forwarding, timeout settings — are where things get interesting. This is how I approach it, with the reasoning behind each decision.
What “SSL Termination at the Proxy” Means
The idea is simple: incoming HTTPS requests hit Nginx, which handles the TLS encryption/decryption. Nginx then forwards the decrypted request to your backend application over plain HTTP (typically on a local port). The application never deals with certificates or TLS — it just gets plain HTTP requests from Nginx.
This works because Nginx and the backend are on the same server (or on a private network where you control all the traffic). The TLS protection covers the public internet portion; the internal forwarding is trusted.
Why do it this way instead of passing TLS directly to the application? Because:
- Most application frameworks don’t handle TLS as well as Nginx does
- Certificate management in one place is simpler than in every application
- Nginx is much better at TLS performance optimization
- You get HTTP/2 and HTTP/3 support for free
Basic Reverse Proxy Setup
Here’s a minimal but production-ready configuration:
# /etc/nginx/sites-available/myapp
upstream backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=63072000" always;
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_cache_bypass $http_upgrade;
}
}
Let me walk through the less-obvious parts. If this is your first time setting up SSL in Nginx, the full Nginx SSL configuration guide covers the certificate side in more depth.
The Header Forwarding Section
The proxy_set_header lines are important. Without them, your application sees every request as coming from 127.0.0.1 (the Nginx proxy), not the actual client.
X-Real-IP — the original client IP address. Your application uses this for logging, rate limiting, geolocation, etc.
X-Forwarded-For — a comma-separated list of IP addresses showing the full proxy chain. If requests go through multiple proxies, this grows. Most applications read the leftmost value as the original client.
X-Forwarded-Proto — tells the backend whether the original request was HTTP or HTTPS. Without this, your application might generate HTTP links when it should generate HTTPS links, or CSRF protection might fail because the app thinks the request came over HTTP.
Host — passes the original hostname to the backend. Without this, the backend sees Nginx’s upstream hostname or IP.
One gotcha I hit: if your application is behind multiple layers of proxy (Nginx + a cloud load balancer), the X-Forwarded-For header can already be set when it reaches Nginx. Using $proxy_add_x_forwarded_for appends to the existing header instead of replacing it, which preserves the full chain.
WebSocket Support
The Upgrade and Connection headers, along with proxy_http_version 1.1, are needed for WebSocket support. If your application uses WebSockets (many real-time apps do), these three lines are required. If not, they don’t hurt anything.
Upstream Block and Keepalive
upstream backend {
server 127.0.0.1:8080;
keepalive 32;
}
The keepalive 32 tells Nginx to maintain up to 32 persistent connections to the upstream. Without this, Nginx opens a new TCP connection for every proxied request, which is inefficient. With keepalive, connections are reused.
You also need proxy_http_version 1.1 in the proxy location — HTTP/1.0 doesn’t support persistent connections.
For multiple backend instances:
upstream backend {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
server 127.0.0.1:8082;
keepalive 64;
}
Nginx round-robins requests across all healthy backends by default.
Timeout Configuration
The defaults are often wrong for modern applications:
location / {
proxy_pass http://backend;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# For large file uploads
client_max_body_size 100m;
client_body_timeout 60s;
}
proxy_read_timeout is the one I adjust most often. The default is 60 seconds, which is fine for most API calls but too short for long-running operations. If users are uploading large files or triggering operations that take a while, you’ll see 504 Gateway Timeout errors.
client_max_body_size defaults to 1MB. If your application accepts file uploads, you need to increase this. Set it on the server block (applies to everything) or on specific location blocks.
Rate Limiting
For any public-facing proxy, rate limiting is worth adding:
# Define a rate limit zone (top-level, outside server blocks)
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
server {
# ...
location /api/ {
limit_req zone=api burst=10 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
}
30r/m means 30 requests per minute per IP. burst=10 allows a burst of 10 additional requests before rate limiting kicks in. nodelay processes burst requests immediately rather than queuing them at the defined rate.
Caching Static Assets
If your application serves static files through the proxy (less common, but it happens):
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
proxy_pass http://backend;
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, immutable";
}
For larger deployments, Nginx’s proxy_cache directive can cache backend responses at the proxy level, reducing load on the backend. But that’s a bigger topic.
Security Headers
The HSTS header is a minimum:
add_header Strict-Transport-Security "max-age=63072000" always;
This tells browsers to always use HTTPS for this domain for the next two years. Once you set this, you can’t easily undo it without waiting for the max-age to expire. Don’t add includeSubDomains unless all your subdomains are HTTPS-only.
Other headers worth adding:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Testing the Configuration
Before and after every change:
# Check syntax
sudo nginx -t
# Reload without dropping connections
sudo nginx -s reload
Test from the outside:
# Verify HTTPS is working
curl -I https://example.com
# Check that HTTP redirects to HTTPS
curl -I http://example.com
# Test proxying (check that backend is reachable through proxy)
curl -v https://example.com/api/health
I also run openssl s_client -connect example.com:443 -servername example.com after SSL-related changes to verify the certificate chain looks correct. For a broader look at OpenSSL commands useful for debugging, that cheatsheet is worth bookmarking. You can also verify the live certificate with the SSL Checker tool.
Common Issues
502 Bad Gateway — Nginx can’t reach the backend. Check that the backend is running on the expected port (ss -tlnp | grep 8080). Check Nginx error logs: tail -f /var/log/nginx/error.log.
504 Gateway Timeout — the backend is reachable but taking too long. Either the backend is slow (check your application logs) or proxy_read_timeout needs to be increased.
Mixed content warnings in browser — your application is generating HTTP URLs inside an HTTPS page. Make sure you’re passing X-Forwarded-Proto and your application is using it correctly to generate absolute URLs.
Redirect loops — if your application redirects HTTP to HTTPS itself AND Nginx does too, you can get a loop. Configure HTTPS redirects in exactly one place.
A well-configured Nginx reverse proxy with SSL termination is a solid, proven pattern for serving web applications. The configuration above covers 90% of real-world setups. The remaining 10% is usually application-specific tuning — timeouts, cache headers, rate limits — that you adjust based on what you observe in production.