A reverse proxy sits between your users and your application servers. Users connect to NGINX; NGINX forwards their requests to a Node.js app on 3000, a Python service on 8000, a Java backend on 8080, or whatever else is running behind the scenes. The user sees one clean HTTPS endpoint; you get TLS termination, caching, security headers, WebSocket support, load balancing, and a sane place to put rate limits — all in one configuration file. This is the practical NGINX reverse proxy configuration guide most teams want when “we put nginx in front of the app” turns out to involve more decisions than expected.
Everything here is tested on the myguard NGINX packages on Debian and Ubuntu. The same configuration works on Angie unchanged — they share the same directives.
The Minimum-Viable Reverse Proxy
This is the smallest sensible nginx reverse proxy setup. A Node.js or Python app listening on 127.0.0.1:3000, NGINX terminates HTTPS and proxies through:
server {
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;
location / {
proxy_pass http://127.0.0.1:3000;
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 X-Forwarded-Host $host;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
That config is enough to ship. Everything below makes it better.
Forwarded Headers: Telling Your Backend Who Visited
By default your Node/Python/Java backend sees the request as coming from 127.0.0.1 — NGINX. The forwarded headers tell it the real client details. Standard set:
- Host — the original
Hostheader the client sent. Without this, your backend may serve the wrong virtual host. - X-Real-IP — the original client IP. Single value.
- X-Forwarded-For — a comma-separated chain of every proxy the request passed through, real client first. Use
$proxy_add_x_forwarded_forrather than hardcoding so the chain accumulates correctly if you sit behind a CDN. - X-Forwarded-Proto — was the original request
httporhttps? Your app generates absolute URLs based on this. - X-Forwarded-Host — same as Host but standardised. Some frameworks prefer it.
Most modern frameworks (Express, Django, Flask, Rails, Laravel) have a “trust proxy” flag — enable it. Without it the framework ignores the forwarded headers and you serve URLs as http://127.0.0.1:3000/....
Keepalive: The Single Biggest Performance Win
Without keepalive, NGINX opens a fresh TCP connection to your backend on every single request. Three-way handshake, slow start, TCP teardown. With keepalive, NGINX pools connections and reuses them. On HTTP/1.1 to a PHP-FPM or Node backend, this often shaves 30-60% off median response time:
upstream backend {
server 127.0.0.1:3000;
keepalive 32; # cache 32 idle connections per worker
keepalive_requests 1000; # recycle each connection after 1000 requests
keepalive_timeout 60s; # close idle connections after 60s
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1; # required for keepalive
proxy_set_header Connection ""; # clear hop-by-hop header
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_http_version 1.1 and the empty Connection header are non-negotiable — without them, keepalive does nothing. This is the single most common reason “my reverse proxy feels slow” turns out to have a one-line fix.
Timeouts: Always Set Them Explicitly
The default NGINX proxy timeouts are 60 seconds. That’s fine for normal requests, terrible if your backend hangs. Always set them explicitly so a stuck backend can’t tie up NGINX workers:
location / {
proxy_pass http://backend;
proxy_connect_timeout 5s; # TCP handshake to backend
proxy_send_timeout 30s; # sending request body to backend
proxy_read_timeout 30s; # waiting for response from backend
# For long-running endpoints (file uploads, streaming):
# proxy_read_timeout 600s;
}
5 seconds is generous for connect — anything longer usually means the backend is dead. 30 seconds for read is a sensible default for typical request handling. Crank read up only on the specific endpoints that legitimately take longer (file uploads, streaming exports, batch operations).
Buffering: The Knob That Caches Responses in NGINX
By default NGINX buffers your backend’s response — it reads it into memory, then sends it to the client at the client’s pace. This protects your backend from slow clients (slowloris-style attacks, mobile users on bad networks). Tune the buffers based on your typical response size:
location / {
proxy_pass http://backend;
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 16 16k;
proxy_busy_buffers_size 32k;
}
For streaming responses (Server-Sent Events, long-polling, websockets) you want buffering OFF so the client gets data as it’s produced:
location /events {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
WebSocket Proxying
WebSockets need the Upgrade and Connection headers forwarded. Standard pattern:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s; # keep idle ws connections open
proxy_send_timeout 3600s;
}
}
The map block translates client Upgrade requests into the right Connection: upgrade for the backend. Without it, the WebSocket handshake fails. The hour-long timeout matches typical WebSocket lifetimes; tune to match your application.
Caching Backend Responses in NGINX
NGINX can cache your backend’s responses with proxy_cache. Useful for cacheable API responses (catalogue endpoints, public dashboards), public read-only pages, and anything that does not vary per user:
http {
proxy_cache_path /var/cache/nginx/backend
levels=1:2 keys_zone=backend_cache:50m
max_size=1g inactive=60m use_temp_path=off;
}
server {
location /api/products {
proxy_pass http://backend;
proxy_cache backend_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 5m;
proxy_cache_valid 404 30s;
proxy_cache_use_stale error timeout updating http_500 http_502;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
}
}
The magic features here are proxy_cache_use_stale (serve stale content when the backend is down — saves your bacon during outages) and proxy_cache_lock (only one request at a time refreshes the cache when it expires — prevents the thundering-herd problem). The X-Cache-Status response header is invaluable for debugging.
Security Headers: Add Them Once, in NGINX
Set security headers in NGINX rather than scattering them across every backend. Centralised, consistent, easy to audit:
server {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
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;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# If you have a CSP, set it here too:
# add_header Content-Security-Policy "default-src 'self'" always;
}
The always flag ensures headers are sent even on error responses. Without it, your 500 pages won’t have HSTS, which technically opens a small downgrade window. The downstream backend can still set its own headers; NGINX’s add_header appends rather than replaces.
Reverse-Proxying Multiple Backends
One reverse proxy in front of multiple services, each with its own path prefix:
upstream node_app {
server 127.0.0.1:3000;
keepalive 16;
}
upstream python_api {
server 127.0.0.1:8000;
keepalive 16;
}
upstream java_search {
server 127.0.0.1:9200;
keepalive 16;
}
server {
listen 443 ssl;
server_name app.example.com;
# Default: Node frontend
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
# /api/* goes to Python
location /api/ {
proxy_pass http://python_api/; # trailing slash strips /api prefix
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# /search/* goes to Elasticsearch / Java
location /search/ {
proxy_pass http://java_search/;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
Watch the trailing slashes on proxy_pass — with a slash, NGINX strips the matched location prefix before forwarding. Without it, NGINX forwards the full path. Get this wrong and your backend serves 404s for routes it definitely has.
Real IP When You Sit Behind a CDN
If Cloudflare (or any CDN) is in front of NGINX, every request looks like it comes from a CDN edge IP. Restore the real client IP with the realip module:
server {
real_ip_header X-Forwarded-For;
set_real_ip_from 173.245.48.0/20; # Cloudflare ranges
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
# ... full Cloudflare list at https://www.cloudflare.com/ips/
location / {
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
After this, $remote_addr in logs and rate-limit keys is the real client IP, not the CDN. Critical for getting rate limiting right when you’re behind Cloudflare.
Health-Checking the Backend
The open-source NGINX has passive health checks — max_fails and fail_timeout on the upstream server line. Sufficient for many cases:
upstream backend {
server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3002 max_fails=3 fail_timeout=10s backup;
keepalive 16;
}
For active health checks (probe /healthz on a schedule and route traffic before users notice an outage), use Angie (free), NGINX Plus, or the nginx_upstream_check_module third-party module. Covered in detail in our NGINX load balancing guide.
Common Mistakes That Bite Eventually
- Forgetting proxy_http_version 1.1 — kills keepalive silently, adds 30+ms TCP setup to every request.
- No timeouts — a hung backend can hold an NGINX worker forever.
- Wrong trailing slash on proxy_pass — backend gets a path it doesn’t recognise.
- Setting X-Forwarded-For with $remote_addr instead of $proxy_add_x_forwarded_for — breaks the proxy chain behind a CDN.
- Buffering on for streaming endpoints — Server-Sent Events and long-polling appear to hang.
- No keepalive on the upstream — every backend request opens a new TCP connection.
Frequently Asked Questions
Related Posts
- NGINX Load Balancing: Upstream Config, Health Checks and Failover — when one backend grows into a fleet.
- NGINX Rate Limiting Guide — protect the backend you just put behind NGINX.
- WordPress NGINX + PHP-FPM Configuration Guide — proxy_pass to PHP-FPM, which is fastcgi_pass technically, but same idea.
- How to Install ModSecurity and OWASP CRS on NGINX — add WAF protection to your reverse proxy.
- TLS Configuration for NGINX and Angie — A+ on SSL Labs for the HTTPS your reverse proxy terminates.