Comprehensive guide to Nginx reverse proxy configuration: upstream blocks, load balancing algorithms, proxy headers, WebSocket and gRPC proxying, caching, and production-ready configurations.
An upstream block defines a group of backend servers that Nginx can proxy requests to.
upstream backend {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 80;
location / {
proxy_pass http://backend;
}
}
upstream backend {
# Basic server
server 127.0.0.1:3000;
# Weighted server - receives 3x traffic
server 127.0.0.1:3001 weight=3;
# Backup server - only used when all primary servers are down
server 127.0.0.1:3002 backup;
# Mark server as permanently unavailable
server 127.0.0.1:3003 down;
# Failure detection: after 3 fails within 30s, mark unavailable for 30s
server 127.0.0.1:3004 max_fails=3 fail_timeout=30s;
# Limit concurrent connections to this server
server 127.0.0.1:3005 max_conns=100;
# Unix socket backend
server unix:/var/run/app.sock;
# Resolve hostname (requires resolver directive)
server backend.service.consul resolve;
}
| Parameter | Default | Description |
|---|---|---|
weight=N |
1 | Relative weight for weighted load balancing |
max_fails=N |
1 | Number of failed attempts before marking unavailable |
fail_timeout=T |
10s | Time to consider fails AND duration to mark unavailable |
backup |
- | Only used when all non-backup servers are unavailable |
down |
- | Permanently marks server as unavailable |
max_conns=N |
0 (unlimited) | Maximum concurrent connections to this server |
resolve |
- | Monitor DNS changes and update upstream automatically |
slow_start=T |
0 | Gradually increase traffic to recovered server (Nginx Plus) |
Distributes requests sequentially across servers. No directive needed.
upstream backend {
# Round-robin is the default - no directive needed
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
Use when: Backends are homogeneous and requests have similar processing time.
upstream backend {
server 127.0.0.1:3000 weight=5; # Gets 5/8 of requests
server 127.0.0.1:3001 weight=2; # Gets 2/8 of requests
server 127.0.0.1:3002 weight=1; # Gets 1/8 of requests
}
Use when: Backends have different capacity (CPU, memory).
Routes to the server with the fewest active connections.
upstream backend {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
Use when: Requests have variable processing time. Prevents slow requests from piling up on one server.
Routes requests from the same client IP to the same backend server (session persistence).
upstream backend {
ip_hash;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
Use when: Application requires sticky sessions and you can't use external session storage.
Caveat: If clients are behind a NAT/proxy, many IPs map to one, causing uneven distribution.
Randomly selects a server for each request.
upstream backend {
random;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
Picks two servers at random, then selects the one with fewer connections (power of two choices).
upstream backend {
random two least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
Use when: Large number of backends where least_conn coordination overhead is high.
Map requests to servers based on a configurable key.
upstream backend {
hash $request_uri consistent;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
Use when: You want cache affinity (same URLs always go to the same backend for better cache hit rates).
The consistent parameter uses ketama consistent hashing, which minimizes redistribution when servers are added/removed.
| Algorithm | Session Persistence | Even Distribution | Variable Request Time | Best For |
|---|---|---|---|---|
| Round-robin | No | Yes (uniform) | Poor | Homogeneous backends |
| Weighted | No | Yes (proportional) | Poor | Mixed-capacity backends |
| Least connections | No | Adapts to load | Good | Variable processing time |
| IP hash | Yes (by IP) | Depends on IPs | Poor | Sticky sessions |
| Random two | No | Good | Good | Large clusters |
| Hash | Yes (by key) | Depends on keys | Poor | Cache affinity |
Nginx detects unhealthy backends based on failed request attempts.
upstream backend {
server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3002 max_fails=3 fail_timeout=30s;
}
How it works:
max_fails times within fail_timeout seconds, it's marked unavailablefail_timeout seconds, Nginx sends one test requestWhat counts as a failure: Controlled by proxy_next_upstream:
location / {
proxy_pass http://backend;
# What errors trigger failover to next upstream
proxy_next_upstream error timeout http_502 http_503 http_504;
# Limit retries across upstream servers
proxy_next_upstream_tries 3;
# Limit total time for all retries
proxy_next_upstream_timeout 10s;
}
For open-source Nginx, use the nginx_upstream_check_module (third-party) or external health check tools.
# Nginx Plus active health check
upstream backend {
zone backend_zone 64k; # Required for active checks
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
location / {
proxy_pass http://backend;
health_check interval=5s fails=3 passes=2 uri=/health;
}
}
# Backend should implement /health returning 200
location /health {
proxy_pass http://backend;
access_log off; # Don't clutter logs
proxy_connect_timeout 2s; # Fail fast
proxy_read_timeout 2s;
}
Every reverse proxy should forward these headers so the backend knows about the original request.
location / {
proxy_pass http://backend;
# Pass the original Host header
proxy_set_header Host $host;
# Client's real IP address
proxy_set_header X-Real-IP $remote_addr;
# Append to existing forwarded-for chain
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Original protocol (http or https)
proxy_set_header X-Forwarded-Proto $scheme;
# Original port
proxy_set_header X-Forwarded-Port $server_port;
}
| Header | Variable | Purpose |
|---|---|---|
Host |
$host |
Original hostname from client request |
X-Real-IP |
$remote_addr |
Client's IP address |
X-Forwarded-For |
$proxy_add_x_forwarded_for |
Chain of proxy IPs |
X-Forwarded-Proto |
$scheme |
Original protocol (http/https) |
X-Forwarded-Port |
$server_port |
Original port number |
X-Request-ID |
$request_id |
Unique request identifier for tracing |
Connection |
"" |
Clear hop-by-hop header for keepalive |
Create a shared include file to avoid repetition:
# /etc/nginx/includes/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;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Request-ID $request_id;
# Usage in server/location blocks
location / {
proxy_pass http://backend;
include /etc/nginx/includes/proxy-headers.conf;
}
location / {
proxy_pass http://backend;
# Remove headers that leak backend info
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
proxy_hide_header X-AspNet-Version;
# Pass through headers that are hidden by default
proxy_pass_header X-Custom-Header;
}
WebSocket requires HTTP/1.1 with the Upgrade mechanism.
# Map to handle Upgrade header
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name ws.example.com;
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# WebSocket upgrade headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Long timeouts for persistent connections
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
server {
listen 443 ssl http2;
server_name ws.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
Socket.IO uses both WebSocket and HTTP long-polling:
location /socket.io/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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;
# Important for Socket.IO long-polling fallback
proxy_buffering off;
proxy_cache off;
}
upstream grpc_backend {
server 127.0.0.1:50051;
}
server {
listen 443 ssl http2;
server_name grpc.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
# Use grpc_pass for gRPC backends
grpc_pass grpc://grpc_backend;
# gRPC-specific headers
grpc_set_header X-Real-IP $remote_addr;
grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
location / {
# grpcs:// for TLS-encrypted gRPC backends
grpc_pass grpcs://grpc_backend;
grpc_ssl_certificate /etc/nginx/certs/client.pem;
grpc_ssl_certificate_key /etc/nginx/certs/client.key;
grpc_ssl_verify on;
grpc_ssl_trusted_certificate /etc/nginx/certs/ca.pem;
}
location / {
grpc_pass grpc://grpc_backend;
# Intercept gRPC errors and return custom responses
grpc_intercept_errors on;
error_page 502 = /error502grpc;
}
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "Backend unavailable";
return 204;
}
http {
# Define cache storage
# levels=1:2 - Two-level directory hierarchy
# keys_zone=cache:10m - 10MB shared memory for cache keys (~80,000 keys)
# max_size=10g - Maximum cache size on disk
# inactive=60m - Remove items not accessed in 60 minutes
# use_temp_path=off - Write directly to cache dir (better performance)
proxy_cache_path /var/cache/nginx
levels=1:2
keys_zone=app_cache:10m
max_size=10g
inactive=60m
use_temp_path=off;
server {
listen 80;
location / {
proxy_pass http://backend;
# Enable caching with the named zone
proxy_cache app_cache;
# Cache different status codes for different durations
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_valid any 5m;
# Custom cache key
proxy_cache_key "$scheme$request_method$host$request_uri";
# Add header to show cache status (HIT, MISS, BYPASS, etc.)
add_header X-Cache-Status $upstream_cache_status;
}
}
}
location / {
proxy_pass http://backend;
proxy_cache app_cache;
proxy_cache_valid 200 10m;
# Bypass cache when specific conditions are met
proxy_cache_bypass $http_cache_control; # Client sends Cache-Control
proxy_cache_bypass $cookie_nocache; # Cookie "nocache" is set
proxy_cache_bypass $arg_nocache; # Query param ?nocache=1
# Don't store in cache under these conditions
proxy_no_cache $http_pragma; # Client sends Pragma: no-cache
proxy_no_cache $arg_nocache;
}
location / {
proxy_pass http://backend;
proxy_cache app_cache;
proxy_cache_valid 200 10m;
# Serve stale content when backend is down or slow
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
# Update cache in background while serving stale
proxy_cache_background_update on;
# Only one request refreshes cache, others get stale
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
}
# Requires ngx_cache_purge module
location ~ /purge(/.*) {
allow 127.0.0.1;
deny all;
proxy_cache_purge app_cache "$scheme$request_method$host$1";
}
Usage: curl -X PURGE https://example.com/purge/api/data
Nginx reads the entire response from the backend, then sends it to the client. Good for fast backends with slow clients.
location / {
proxy_pass http://backend;
# Buffering on (default)
proxy_buffering on;
# Size of the buffer for the first part of the response (headers)
proxy_buffer_size 8k;
# Number and size of buffers for the response body
proxy_buffers 8 16k;
# Maximum size that can be busy sending to client
proxy_busy_buffers_size 32k;
# Temporary files if response exceeds buffers
proxy_temp_file_write_size 64k;
proxy_max_temp_file_size 1024m;
}
Send data to client as soon as it arrives from backend. Required for streaming.
location /stream/ {
proxy_pass http://backend;
# Disable buffering for streaming responses
proxy_buffering off;
# Also disable request body buffering
proxy_request_buffering off;
}
Disable buffering for:
location /events/ {
proxy_pass http://backend;
proxy_http_version 1.1;
# Critical for SSE
proxy_buffering off;
proxy_cache off;
# Don't add compression (breaks streaming)
proxy_set_header Accept-Encoding "";
# Keep connection alive
proxy_set_header Connection "";
proxy_read_timeout 86400s;
# Chunked transfer
chunked_transfer_encoding on;
}
Reuse connections to upstream servers instead of opening a new TCP connection per request.
upstream backend {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
# Keep up to 32 idle connections alive per worker process
keepalive 32;
# Maximum requests per keepalive connection before closing
keepalive_requests 1000;
# Idle timeout for keepalive connections
keepalive_timeout 60s;
}
server {
location / {
proxy_pass http://backend;
# Required for keepalive to work with upstream
proxy_http_version 1.1;
proxy_set_header Connection "";
# Standard headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Important: proxy_http_version 1.1 and proxy_set_header Connection "" are both required. HTTP/1.0 uses Connection: close by default, which prevents keepalive.
| Scenario | keepalive Value |
Rationale |
|---|---|---|
| Low traffic | 8-16 | Minimal idle connections |
| Medium traffic | 32-64 | Balance memory vs connection reuse |
| High traffic | 128-256 | Maximize connection reuse |
| Microservices | 16-32 per upstream | Per-service pools |
location / {
proxy_pass http://backend;
# Time to establish connection to backend
proxy_connect_timeout 5s; # Default: 60s
# Time to wait for backend to start sending response
proxy_read_timeout 60s; # Default: 60s
# Time allowed to send request body to backend
proxy_send_timeout 60s; # Default: 60s
}
| Timeout | Typical Value | Use Case |
|---|---|---|
proxy_connect_timeout |
3-5s | Fail fast if backend is unreachable |
proxy_read_timeout |
30-60s | API responses, page rendering |
proxy_read_timeout |
300s+ | File uploads, long reports |
proxy_read_timeout |
3600s | WebSocket, SSE |
proxy_send_timeout |
30-60s | Most applications |
# Fast API endpoint
location /api/ {
proxy_pass http://backend;
proxy_connect_timeout 3s;
proxy_read_timeout 10s;
}
# File upload endpoint
location /upload/ {
proxy_pass http://backend;
proxy_connect_timeout 5s;
proxy_read_timeout 300s;
client_max_body_size 100m;
}
# Report generation (slow)
location /reports/ {
proxy_pass http://backend;
proxy_connect_timeout 5s;
proxy_read_timeout 600s;
}
upstream nodejs {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 443 ssl http2;
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;
include /etc/nginx/includes/ssl-params.conf;
# Security headers
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;
# Proxy to Node.js
location / {
proxy_pass http://nodejs;
proxy_http_version 1.1;
proxy_set_header Connection "";
include /etc/nginx/includes/proxy-headers.conf;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
# Handle large JWT tokens
proxy_buffer_size 16k;
proxy_buffers 4 32k;
}
# Serve static files directly (bypass Node.js)
location /static/ {
alias /var/www/app/public/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# WebSocket endpoint
location /ws {
proxy_pass http://nodejs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
}
}
upstream gunicorn {
server unix:/run/gunicorn/app.sock fail_timeout=10s;
keepalive 16;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
include /etc/nginx/includes/ssl-params.conf;
client_max_body_size 10m;
location / {
proxy_pass http://gunicorn;
proxy_http_version 1.1;
proxy_set_header Connection "";
include /etc/nginx/includes/proxy-headers.conf;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_buffer_size 8k;
proxy_buffers 4 16k;
}
# Django static files
location /static/ {
alias /var/www/app/staticfiles/;
expires 30d;
access_log off;
}
# Django media uploads
location /media/ {
alias /var/www/app/media/;
expires 7d;
}
}
upstream goapp {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name service.example.com;
ssl_certificate /etc/letsencrypt/live/service.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/service.example.com/privkey.pem;
include /etc/nginx/includes/ssl-params.conf;
# Go apps typically serve their own static files
location / {
proxy_pass http://goapp;
proxy_http_version 1.1;
proxy_set_header Connection "";
include /etc/nginx/includes/proxy-headers.conf;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
# Go apps handle large payloads efficiently
proxy_request_buffering off;
}
# Health check
location /healthz {
proxy_pass http://goapp;
access_log off;
proxy_connect_timeout 2s;
proxy_read_timeout 2s;
}
}
upstream php-fpm {
server unix:/run/php/php8.3-fpm.sock;
}
server {
listen 443 ssl http2;
server_name site.example.com;
ssl_certificate /etc/letsencrypt/live/site.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/site.example.com/privkey.pem;
include /etc/nginx/includes/ssl-params.conf;
root /var/www/site/public;
index index.php index.html;
client_max_body_size 50m;
# Try static file first, then directory, then PHP
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP processing
location ~ \.php$ {
fastcgi_pass php-fpm;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_connect_timeout 5s;
fastcgi_send_timeout 30s;
fastcgi_read_timeout 30s;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
}
# Deny access to hidden files (except .well-known)
location ~ /\.(?!well-known) {
deny all;
}
# Static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
}
# Upstream definitions for each service
upstream api_service {
server 127.0.0.1:3000;
keepalive 32;
}
upstream admin_service {
server 127.0.0.1:4000;
keepalive 16;
}
upstream docs_service {
server 127.0.0.1:5000;
keepalive 8;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/nginx/includes/ssl-params.conf;
# API service at /api/
location /api/ {
proxy_pass http://api_service/; # Trailing / strips /api/ prefix
proxy_http_version 1.1;
proxy_set_header Connection "";
include /etc/nginx/includes/proxy-headers.conf;
# API-specific settings
proxy_read_timeout 30s;
client_max_body_size 10m;
# Rate limiting for API
limit_req zone=api burst=20 nodelay;
}
# Admin panel at /admin/
location /admin/ {
proxy_pass http://admin_service/;
proxy_http_version 1.1;
proxy_set_header Connection "";
include /etc/nginx/includes/proxy-headers.conf;
# Restrict admin access by IP
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
}
# Documentation at /docs/
location /docs/ {
proxy_pass http://docs_service/;
proxy_http_version 1.1;
proxy_set_header Connection "";
include /etc/nginx/includes/proxy-headers.conf;
# Cache documentation pages
proxy_cache app_cache;
proxy_cache_valid 200 1h;
}
# Frontend SPA (catch-all)
location / {
root /var/www/frontend/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
}
# Alternative: subdomain-based routing
server {
listen 443 ssl http2;
server_name api.example.com;
include /etc/nginx/includes/ssl-params.conf;
location / {
proxy_pass http://api_service;
include /etc/nginx/includes/proxy-headers.conf;
}
}
server {
listen 443 ssl http2;
server_name admin.example.com;
include /etc/nginx/includes/ssl-params.conf;
location / {
proxy_pass http://admin_service;
include /etc/nginx/includes/proxy-headers.conf;
}
}