NGINX as a Reverse Proxy: Configuration That Works
Set up NGINX as a reverse proxy for Node, Python, or container backends with the right headers, timeouts, and TLS configuration for production.
What you'll learn
- ✓What a reverse proxy is and why you want one
- ✓How to write a proxy_pass config that works in production
- ✓How to set the right forwarded headers
- ✓How to terminate TLS with sensible defaults
- ✓How to load-balance across multiple backends
Prerequisites
- •Comfortable with the Linux command line
- •Familiar with containerized apps — see What is Docker
A reverse proxy sits between clients and your application servers. NGINX is the most common one in the world. It terminates TLS, serves static files, routes requests to the right backend, smooths out load, and gives you one place to add headers, rate limits, and access controls. This post walks through the config you actually need, not the kitchen sink.
What a reverse proxy buys you
Without a proxy, your Node or Python process answers public traffic directly. That works in development but creates real problems in production:
- TLS in every app process means duplicated certs and slow handshakes.
- No central place to add rate limiting, IP allowlists, or request logging.
- Restarts drop connections because there is no buffer.
- Static assets compete with API traffic for the event loop.
NGINX handles all of that as a separate process. Your app speaks plain HTTP on a private port; NGINX speaks HTTPS to the world.
A minimal working config
# /etc/nginx/sites-available/api.conf
upstream api_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name api.codeloom.dev;
location / {
proxy_pass http://api_backend;
proxy_http_version 1.1;
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 Connection "";
}
}
This is the shape every production proxy starts with. A few details matter:
upstreamblock names a pool of backends. Even one server should go in an upstream so you can add more later without editing locations.keepalivereuses TCP connections to the backend. Big performance win.proxy_http_version 1.1plus the emptyConnectionheader is required for keepalive to work.- Forwarded headers tell your app the original client IP and protocol. Without them, your logs and security checks see only
127.0.0.1.
Reading forwarded headers in the app
Your app must trust the proxy. In Express, that is one line:
app.set('trust proxy', 'loopback');
In Django, set SECURE_PROXY_SSL_HEADER and configure USE_X_FORWARDED_HOST. The exact knobs vary, but the principle is the same: only trust forwarded headers when the immediate peer is the proxy.
Terminating TLS
Modern NGINX with a current OpenSSL is the easiest TLS terminator there is. Use a real certificate (Let’s Encrypt via certbot is fine) and pick a sane cipher set.
server {
listen 443 ssl http2;
server_name api.codeloom.dev;
ssl_certificate /etc/letsencrypt/live/api.codeloom.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.codeloom.dev/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://api_backend;
# ... same headers as before
}
}
server {
listen 80;
server_name api.codeloom.dev;
return 301 https://$host$request_uri;
}
Notes:
http2is a one-word win.Strict-Transport-Securitytells browsers to refuse HTTP forever. Only set it once you are confident HTTPS works on all subdomains you cover.- The second
serverblock does a permanent redirect from port 80 to 443.
Timeouts that match your app
Defaults are generous. Tighten them so failures fail fast.
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
send_timeout 30s;
client_max_body_size 10m;
If your app handles long uploads or server-sent events, bump proxy_read_timeout for that specific location only. Do not raise it globally.
Load balancing
Add servers to the upstream and NGINX round-robins by default.
upstream api_backend {
server 10.0.0.11:3000 max_fails=3 fail_timeout=10s;
server 10.0.0.12:3000 max_fails=3 fail_timeout=10s;
server 10.0.0.13:3000 backup;
keepalive 64;
}
Other algorithms: least_conn for uneven request times, ip_hash for sticky sessions (use sparingly), or commercial hash for consistent hashing. max_fails and fail_timeout give passive health checks.
For true active health checks against an HTTP endpoint, you need NGINX Plus or you wrap NGINX with a control plane that updates the upstream dynamically. In containerized environments, Kubernetes Services handle this for you — see What is Kubernetes for the deeper picture.
Static files and caching
If your app serves static assets, let NGINX do it directly.
location /static/ {
alias /var/www/codeloom/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
This skips the backend entirely for assets. With a hashed filename strategy from your build, immutable is safe.
WebSockets
WebSocket connections start as HTTP and upgrade. NGINX needs three extra headers in that location.
location /ws/ {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
}
The long read timeout matters because WebSocket connections are quiet for long stretches.
Rate limiting
Two lines protect a login endpoint from brute force.
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
location /auth/login {
limit_req zone=login burst=10 nodelay;
proxy_pass http://api_backend;
}
5r/s allows five requests per second per IP with a burst of ten. Excess gets a 503. Tune the rate to your real traffic.
Reloading without dropped connections
Edit the config, test it, reload it. NGINX swaps the config in place.
sudo nginx -t
sudo systemctl reload nginx
reload keeps existing connections on the old workers until they finish, then spins up new workers with the new config. Zero dropped requests.
Pitfalls
- Forgetting
proxy_set_header Host $host. Many apps base routing or virtual hosts on it. - Setting
proxy_buffering offbecause someone said so. Default buffering is usually correct. - Hardcoding upstream IPs. Use DNS names with
resolverif backends move. - Ignoring the access log. It is the cheapest observability you have.
Wrap up
NGINX as a reverse proxy is a small set of well-understood directives doing a lot of useful work. An upstream block, a server block, the right forwarded headers, TLS with modern defaults, and tightened timeouts. Add load balancing, WebSockets, rate limiting, and static serving in the locations that need them. The result is a single, scriptable front door for any backend you can speak HTTP to.