Nginx Reverse Proxy & Caching: The Complete Guide
Nginx started as a web server but its real power in modern infrastructure is as a reverse proxy. It sits between clients and your application servers, handling SSL termination, response caching, compression, rate limiting, and load balancing so your application code does not have to carry that burden. With over 400 million sites using it and a proven track record at companies like Netflix, Dropbox, and Cloudflare, Nginx is the most widely deployed reverse proxy on the internet.
This guide covers every practical configuration you need for production deployments, from basic proxy pass through advanced caching strategies, SSL hardening, WebSocket support, and performance tuning.
Reverse Proxy vs Web Server
When Nginx serves files from disk, it acts as a web server. When it forwards requests to another service and returns the response to the client, it acts as a reverse proxy. Most production Nginx deployments do both -- serving static assets directly from the filesystem while proxying dynamic requests to application servers running Node.js, Python, Go, Java, or any other backend.
The key directive that transforms Nginx from a web server into a reverse proxy is proxy_pass:
server {
listen 80;
server_name example.com;
# Static files served directly from disk (fast)
location /static/ {
root /var/www/example;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Everything else proxied to the application (dynamic)
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
This separation is powerful. Static files bypass your application entirely, consuming minimal CPU. Only dynamic requests hit your backend, and Nginx handles all the connection management, buffering, and protocol translation.
Understanding the Request Flow
When Nginx acts as a reverse proxy, every request passes through a well-defined pipeline:
Client Request (HTTPS)
|
v
+-------------------------+
| Nginx Listener | Accepts connection, terminates SSL
| (listen 443 ssl) |
+-------------------------+
|
v
+-------------------------+
| Server Block Selection | Matches server_name to request Host header
+-------------------------+
|
v
+-------------------------+
| Location Matching | Finds the best matching location block
+-------------------------+
|
v
+-------------------------+
| Access Controls | Rate limiting, IP allow/deny, auth
+-------------------------+
|
v
+-------------------------+
| Cache Lookup | Checks proxy_cache for a valid entry
+-------------------------+
| |
| (MISS) | (HIT)
v v
+-------------------------+ Return cached
| Upstream Connection | response
| (proxy_pass) |
+-------------------------+
|
v
+-------------------------+
| Response Processing | Buffering, compression, header modification
+-------------------------+
|
v
Client receives response
Upstream Blocks and Load Balancing
For multiple backend servers, define an upstream block. This is how Nginx distributes traffic across your application fleet:
upstream app_backend {
least_conn;
server 10.0.1.10:3000 weight=3 max_fails=3 fail_timeout=30s;
server 10.0.1.11:3000 weight=2 max_fails=3 fail_timeout=30s;
server 10.0.1.12:3000 weight=1 max_fails=3 fail_timeout=30s;
# Backup server only receives traffic when all primary servers are down
server 10.0.1.13:3000 backup;
# Keep persistent connections to backends
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
The max_fails=3 fail_timeout=30s parameters implement passive health checking. If a server fails 3 times within 30 seconds, Nginx marks it as unavailable for the next 30 seconds. After that period, Nginx tries the server again.
Load Balancing Methods
| Method | Directive | Behavior | Best For |
|---|---|---|---|
| Round Robin | (default) | Distributes requests sequentially, respects weights | General purpose, stateless apps |
| Least Connections | least_conn | Routes to server with fewest active connections | Variable request duration, long-running requests |
| IP Hash | ip_hash | Consistent mapping of client IP to server | Session affinity without cookies |
| Generic Hash | hash $request_uri consistent | Consistent hashing on any variable | Cache distribution, content-based routing |
| Random | random two least_conn | Picks two random servers, chooses the one with fewer connections | Large pools where least_conn tracking is expensive |
The consistent keyword with hash enables consistent hashing, which minimizes redistribution when servers are added or removed. This is critical for caching layers where you want cache locality.
Keepalive Connections to Backends
The keepalive 32 directive is one of the most impactful performance settings. Without it, Nginx opens and closes a new TCP connection to the backend for every request. With keepalive, connections are reused:
upstream app_backend {
server 10.0.1.10:3000;
server 10.0.1.11:3000;
keepalive 32; # Keep up to 32 idle connections per worker
keepalive_requests 1000; # Max requests per connection before recycling
keepalive_timeout 60s; # Close idle connections after 60s
}
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1; # Required for keepalive
proxy_set_header Connection ""; # Clear the Connection header
}
Both proxy_http_version 1.1 and clearing the Connection header are mandatory for upstream keepalive to work. HTTP/1.0 closes connections by default, and the default Connection: close header tells the backend to close after each response.
Proxy Headers
When Nginx proxies a request, the backend sees Nginx's IP instead of the client's. Without proper header forwarding, your application loses critical client information. Set up a reusable snippet:
# /etc/nginx/proxy_params (or include inline)
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;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Request-ID $request_id;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
Then include it in your location blocks:
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
include proxy_params;
}
Header explanations:
| Header | Purpose | Used By |
|---|---|---|
Host | Original hostname from the client request | Backend virtual hosting, routing |
X-Real-IP | Direct client IP address | Application logging, geo-IP |
X-Forwarded-For | Chain of all proxy IPs the request passed through | Audit trails, rate limiting at app level |
X-Forwarded-Proto | Whether the client used HTTP or HTTPS | Redirect logic, secure cookie decisions |
X-Request-ID | Unique identifier for the request | Distributed tracing, log correlation |
Your application should read X-Real-IP or X-Forwarded-For to get the actual client address. In Express.js, set app.set('trust proxy', true). In Django, configure SECURE_PROXY_SSL_HEADER. In Go, use r.Header.Get("X-Real-IP").
SSL Termination with Let's Encrypt
Quick Setup with Certbot
The fastest path to HTTPS:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot automatically modifies your Nginx config, obtains certificates, and sets up auto-renewal.
Manual Configuration with Hardened Security
For full control and an A+ rating on SSL Labs:
server {
listen 80;
server_name example.com www.example.com;
# ACME challenge for Let's Encrypt renewal
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# Certificate files
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern protocol configuration (TLSv1.2 + TLSv1.3)
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;
# OCSP stapling (proves certificate validity without client contacting CA)
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Session caching (reduces handshake overhead for returning clients)
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# DH parameters for forward secrecy
ssl_dhparam /etc/nginx/dhparam.pem;
# HSTS (tell browsers to always use HTTPS)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
location / {
proxy_pass http://app_backend;
include proxy_params;
}
}
Generate the DH parameters file (do this once, it takes a few minutes):
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048
SSL Performance Tuning
SSL/TLS performance is dominated by the handshake. Each full handshake costs 2 round trips and significant CPU. Here is how to minimize that:
| Optimization | Directive | Impact |
|---|---|---|
| Session cache | ssl_session_cache shared:SSL:50m | Avoids full handshake for returning clients. 50MB holds ~200,000 sessions. |
| Session timeout | ssl_session_timeout 1d | Sessions valid for 24 hours |
| OCSP stapling | ssl_stapling on | Client does not need to contact CA for revocation check |
| HTTP/2 | listen 443 ssl http2 | Multiplexes requests over a single connection |
| Early data (TLS 1.3) | ssl_early_data on | 0-RTT resumption (use with caution for replay risk) |
Auto-Renewal
# /etc/cron.d/certbot-renew
0 3 * * * root certbot renew --quiet --post-hook "systemctl reload nginx"
Or use systemd timers, which are more reliable than cron:
sudo systemctl enable --now certbot-renew.timer
Response Caching
Nginx can cache responses from your backend, dramatically reducing load for content that does not change on every request. A well-configured cache can absorb 80-95% of your read traffic, letting a small backend fleet handle enormous volumes.
Setting Up the Cache Zone
Define the cache zone in the http block (this goes in /etc/nginx/nginx.conf or a file included at the http level):
http {
# Primary application cache
proxy_cache_path /var/cache/nginx/app
levels=1:2
keys_zone=app_cache:20m
max_size=2g
inactive=60m
use_temp_path=off
min_free=256m;
# Separate cache for API responses (shorter TTLs)
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:10m
max_size=500m
inactive=10m
use_temp_path=off;
}
Parameter reference:
| Parameter | Purpose | Sizing Guidance |
|---|---|---|
levels=1:2 | Directory hashing structure (one char, then two chars) | Standard value, no need to change |
keys_zone=app_cache:20m | Shared memory for cache keys. 1MB holds ~8,000 keys. | 10m for small sites, 50m+ for millions of URLs |
max_size=2g | Maximum disk space for cached content | Size based on available disk and content volume |
inactive=60m | Remove entries not accessed in 60 minutes | Match to your content change frequency |
use_temp_path=off | Write directly to cache directory | Always set off to reduce I/O overhead |
min_free=256m | Minimum free disk space to maintain | Prevents filling the disk completely |
Enabling Caching Per Location
location /api/products {
proxy_pass http://app_backend;
proxy_cache api_cache;
proxy_cache_valid 200 10m;
proxy_cache_valid 301 302 1h;
proxy_cache_valid 404 1m;
proxy_cache_valid any 0;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_methods GET HEAD;
proxy_cache_min_uses 2;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Show cache status in response header
add_header X-Cache-Status $upstream_cache_status always;
}
Key directives explained:
| Directive | Purpose |
|---|---|
proxy_cache_valid 200 10m | Cache 200 responses for 10 minutes |
proxy_cache_min_uses 2 | Only cache after 2 requests (avoids caching one-off URLs) |
proxy_cache_use_stale | Serve stale content when backend is down or slow |
proxy_cache_background_update | Refresh stale content in the background while serving old version |
proxy_cache_lock | When multiple requests hit the same uncached URL simultaneously, only one goes to the backend. Others wait for the cache to fill. |
proxy_cache_lock_timeout | How long to wait for the lock before going to the backend directly |
The proxy_cache_use_stale directive is one of the most important settings for reliability. When your backend goes down or returns 500 errors, Nginx serves the last good cached response instead of passing the error through to clients. Combined with proxy_cache_background_update, clients always see fast responses while Nginx refreshes content behind the scenes.
Cache Status Values
The $upstream_cache_status variable reveals what happened during cache lookup:
| Status | Meaning |
|---|---|
HIT | Response served from cache |
MISS | Response not in cache, fetched from backend |
EXPIRED | Cache entry expired, fetched fresh from backend |
STALE | Served expired content because backend is unavailable |
UPDATING | Stale content served while background update runs |
REVALIDATED | Backend confirmed cache entry is still valid (304) |
BYPASS | Cache deliberately skipped due to bypass rules |
Cache Bypass
Allow certain requests to skip the cache entirely:
location /api/ {
proxy_pass http://app_backend;
proxy_cache api_cache;
proxy_cache_valid 200 5m;
# Never cache authenticated requests
proxy_cache_bypass $http_authorization $cookie_session;
proxy_no_cache $http_authorization $cookie_session;
# Allow manual purge via header
proxy_cache_bypass $http_x_purge_cache;
# Never cache POST/PUT/DELETE
proxy_cache_methods GET HEAD;
# Skip cache for requests with query string "nocache=1"
set $skip_cache 0;
if ($arg_nocache = "1") {
set $skip_cache 1;
}
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache;
add_header X-Cache-Status $upstream_cache_status always;
}
The distinction between proxy_cache_bypass and proxy_no_cache matters: proxy_cache_bypass skips reading from the cache (goes to backend), while proxy_no_cache prevents writing the response to the cache. Use both together when you want a request to completely ignore the cache.
Cache Invalidation
For active purging, you need either the ngx_cache_purge third-party module or Nginx Plus:
# Requires ngx_cache_purge module
location ~ /purge(/.*) {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
proxy_cache_purge app_cache "$scheme$request_method$host$1";
}
Purge a specific URL:
curl -X PURGE http://localhost/purge/api/products
Without the purge module, you can clear the entire cache directory:
# Clear all cached content
rm -rf /var/cache/nginx/app/*
# Or use find for selective clearing
find /var/cache/nginx/api -type f -mmin +30 -delete
Micro-Caching for Dynamic Content
Even caching dynamic content for 1 second (micro-caching) can absorb traffic spikes:
location / {
proxy_pass http://app_backend;
proxy_cache app_cache;
proxy_cache_valid 200 1s;
proxy_cache_lock on;
proxy_cache_use_stale updating;
proxy_cache_background_update on;
add_header X-Cache-Status $upstream_cache_status always;
}
With micro-caching, if 1000 requests arrive in the same second, only 1 hits your backend. The other 999 get the cached response. This is extremely effective for handling traffic spikes from social media links or DDoS traffic.
Rate Limiting
Protect your backend from traffic spikes, abuse, and brute force attacks:
http {
# Define rate limit zones in shared memory
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=search:10m rate=5r/s;
# Use 429 instead of default 503
limit_req_status 429;
# Log rate-limited requests at warn level (not error)
limit_req_log_level warn;
}
server {
# General rate limit for the entire site
limit_req zone=general burst=50 nodelay;
# Stricter limit on API endpoints
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app_backend;
}
# Very strict limit on authentication
location /api/auth/login {
limit_req zone=login burst=5;
proxy_pass http://app_backend;
}
# Moderate limit on search (expensive queries)
location /api/search {
limit_req zone=search burst=10 nodelay;
proxy_pass http://app_backend;
}
}
Rate limiting parameters explained:
| Parameter | Effect |
|---|---|
rate=10r/s | Sustained rate of 10 requests per second per IP |
burst=20 | Allow up to 20 excess requests to queue |
nodelay | Process burst requests immediately instead of spacing them out |
delay=10 | Process first 10 burst requests immediately, delay the rest |
The delay parameter (available since Nginx 1.15.7) provides a middle ground between nodelay and no delay. For example, burst=20 delay=10 processes the first 10 excess requests immediately and queues the remaining 10 with spacing. This is ideal for API endpoints that see occasional bursts but should not be overwhelmed.
Rate Limiting by API Key
For APIs where you want per-key limits instead of per-IP:
map $http_x_api_key $api_key_limit {
default $binary_remote_addr;
"~." $http_x_api_key;
}
limit_req_zone $api_key_limit zone=api_keyed:20m rate=100r/s;
Connection Limiting
Limit concurrent connections from a single IP. This is separate from rate limiting and protects against slow clients holding connections open:
http {
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
limit_conn_zone $server_name zone=conn_per_server:10m;
}
server {
# Max 100 concurrent connections per IP
limit_conn conn_per_ip 100;
# Max 10,000 total connections to this server block
limit_conn conn_per_server 10000;
limit_conn_status 429;
location /downloads/ {
# Stricter limit for resource-heavy downloads
limit_conn conn_per_ip 5;
limit_rate 1m; # Limit bandwidth to 1MB/s per connection
proxy_pass http://app_backend;
}
}
Gzip and Brotli Compression
Compress responses to reduce bandwidth. Compression can cut transfer sizes by 60-80% for text-based content:
http {
# Gzip (universally supported)
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 4;
gzip_min_length 256;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/vnd.ms-fontobject
application/wasm
application/x-javascript
application/xml
font/eot
font/opentype
font/otf
image/bmp
image/svg+xml
text/cache-manifest
text/calendar
text/css
text/javascript
text/markdown
text/plain
text/vcard
text/xml;
# Do not compress already-compressed formats
gzip_disable "msie6";
}
A compression level of 4 offers a good balance between CPU usage and compression ratio. Level 1 gives minimal compression with minimal CPU. Level 9 squeezes out a few more percent but at 3-4x the CPU cost. Going higher than 6 yields diminishing returns for almost all content types.
For Brotli (better compression ratio, supported by all modern browsers), you need the ngx_brotli module:
http {
brotli on;
brotli_comp_level 4;
brotli_min_length 256;
brotli_types
application/json
application/javascript
text/css
text/html
text/plain
text/xml
image/svg+xml;
}
Security Headers
Add security headers at the proxy level so every response includes them, regardless of what your backend returns:
server {
# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;
# XSS protection (legacy, but still useful for older browsers)
add_header X-XSS-Protection "1; mode=block" always;
# Control referrer information
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'" always;
# Permissions policy (replaces Feature-Policy)
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# HSTS (only add on HTTPS server blocks)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Hide Nginx version from response headers
server_tokens off;
# Prevent access to hidden files (dotfiles)
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Prevent access to backup files
location ~* \.(bak|config|dist|fla|inc|ini|log|psd|sh|sql|swp)$ {
deny all;
access_log off;
log_not_found off;
}
}
The always keyword ensures headers are added even for error responses (4xx, 5xx). Without it, Nginx only adds headers to successful responses, which leaves error pages unprotected.
Proxy Buffering
Buffering controls how Nginx handles responses from backends. This is one of the most misunderstood but impactful areas of Nginx configuration.
location /api/ {
proxy_pass http://app_backend;
# Enable buffering (default behavior)
proxy_buffering on;
proxy_buffer_size 4k; # Buffer for response headers (first part)
proxy_buffers 8 16k; # 8 buffers of 16k each for the response body
proxy_busy_buffers_size 32k; # Max size of buffers being sent to client
# Temp file for responses larger than in-memory buffers
proxy_max_temp_file_size 1024m;
proxy_temp_file_write_size 64k;
}
location /api/stream {
proxy_pass http://app_backend;
# Disable buffering for streaming endpoints
proxy_buffering off;
proxy_cache off;
# Chunked transfer encoding
chunked_transfer_encoding on;
}
location /api/events {
proxy_pass http://app_backend;
# Server-Sent Events (SSE) configuration
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection "";
proxy_http_version 1.1;
# Disable response buffering at the OS level
proxy_read_timeout 3600s;
send_timeout 3600s;
}
How buffering works: with buffering enabled, Nginx reads the entire response from the backend into memory (or a temp file for large responses), then sends it to the client. This frees up the backend connection quickly, even if the client is on a slow connection. Without buffering, the backend connection stays open until the client finishes receiving the response.
| Scenario | Buffering | Reason |
|---|---|---|
| Standard API requests | On | Frees backend connections quickly |
| File downloads | On | Backend is released while slow clients download |
| Server-Sent Events (SSE) | Off | Events must arrive in real time |
| Streaming responses | Off | Content must flow continuously |
| Long-polling | Off | Connection must stay open |
| gRPC streaming | Off | Bidirectional stream must not be buffered |
WebSocket Proxying
WebSocket connections require special handling because they upgrade from HTTP to a persistent bidirectional protocol:
upstream ws_backend {
server 10.0.1.10:3001;
server 10.0.1.11:3001;
ip_hash; # Sticky sessions for WebSocket connections
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name ws.example.com;
location /ws/ {
proxy_pass http://ws_backend;
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;
# Longer timeouts for idle WebSocket connections
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Disable buffering for real-time communication
proxy_buffering off;
}
}
The map directive sets the Connection header to upgrade when the Upgrade header is present, and to close otherwise. This is the standard pattern for handling both WebSocket and regular HTTP connections in the same server block.
Note the use of ip_hash in the upstream. WebSocket connections are long-lived, so you want a client's initial HTTP upgrade request and subsequent frames to always reach the same backend.
Performance Tuning
Worker Configuration
# Auto-detect CPU cores
worker_processes auto;
# Max open file descriptors per worker
worker_rlimit_nofile 65535;
events {
# Max simultaneous connections per worker
worker_connections 16384;
# Use epoll on Linux for efficient event handling
use epoll;
# Accept as many connections as possible at once
multi_accept on;
}
Connection and Buffer Tuning
http {
# Sendfile for efficient static file serving
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# Timeouts
keepalive_timeout 65;
keepalive_requests 1000;
client_body_timeout 12;
client_header_timeout 12;
send_timeout 10;
# Buffer sizes
client_body_buffer_size 16k;
client_header_buffer_size 1k;
client_max_body_size 50m;
large_client_header_buffers 4 16k;
# Open file cache (reduces syscalls for frequently accessed files)
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}
System-Level Tuning
# /etc/sysctl.d/99-nginx.conf
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_max_tw_buckets = 1440000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
Performance Comparison Table
| Configuration | Requests/sec (approx) | Notes |
|---|---|---|
| Default Nginx config | 10,000-15,000 | Out of the box, no tuning |
| Tuned workers + keepalive | 25,000-40,000 | Worker count, keepalive to backends |
| With proxy caching (HIT) | 50,000-100,000+ | Cached responses bypass backend entirely |
| Static file serving | 100,000-200,000+ | sendfile, open_file_cache |
Numbers vary enormously by hardware, request size, and workload. The point is that each optimization layer compounds.
Structured Logging
Replace the default combined log format with JSON for easy parsing by log aggregation tools:
http {
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request_method":"$request_method",'
'"request_uri":"$request_uri",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_response_time":"$upstream_response_time",'
'"upstream_addr":"$upstream_addr",'
'"http_user_agent":"$http_user_agent",'
'"http_referer":"$http_referer",'
'"request_id":"$request_id",'
'"cache_status":"$upstream_cache_status"'
'}';
access_log /var/log/nginx/access.json json_combined buffer=64k flush=5s;
# Separate error log
error_log /var/log/nginx/error.log warn;
}
The buffer=64k flush=5s parameters batch log writes for better I/O performance. Logs are flushed either when the buffer fills up or every 5 seconds, whichever comes first.
Production Configuration
Here is a complete production config combining everything covered above:
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;
events {
worker_connections 16384;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
# Buffers
client_body_buffer_size 16k;
client_max_body_size 50m;
# Cache zones
proxy_cache_path /var/cache/nginx/app
levels=1:2 keys_zone=app_cache:20m max_size=2g
inactive=60m use_temp_path=off;
proxy_cache_path /var/cache/nginx/api
levels=1:2 keys_zone=api_cache:10m max_size=500m
inactive=10m use_temp_path=off;
# Rate limit zones
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=2r/s;
limit_req_status 429;
# Compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 4;
gzip_min_length 256;
gzip_types application/json application/javascript application/xml
text/css text/javascript text/plain text/xml image/svg+xml;
# Logging
log_format json_combined escape=json
'{"time":"$time_iso8601","addr":"$remote_addr","method":"$request_method",'
'"uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,'
'"rt":$request_time,"upstream":"$upstream_response_time",'
'"cache":"$upstream_cache_status","ua":"$http_user_agent"}';
access_log /var/log/nginx/access.json json_combined buffer=64k flush=5s;
# Upstream
upstream app {
least_conn;
server 10.0.1.10:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
keepalive 32;
keepalive_requests 1000;
}
# WebSocket upgrade map
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP to HTTPS redirect
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
# Main HTTPS server
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# SSL
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_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
server_tokens off;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# General rate limit
limit_req zone=general burst=50 nodelay;
# Static files (served directly, long cache)
location /static/ {
root /var/www/example;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# API with response caching
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache api_cache;
proxy_cache_valid 200 5m;
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
include proxy_params;
}
# Auth endpoints with strict rate limiting, no cache
location /api/auth/ {
limit_req zone=auth burst=5;
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache off;
include proxy_params;
}
# WebSocket
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
include proxy_params;
}
# Default proxy
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
include proxy_params;
}
# Block hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
}
Testing, Validation, and Troubleshooting
Validation
Always validate before reloading:
# Test config syntax
sudo nginx -t
# Test and dump the full compiled config
sudo nginx -T
# Reload without downtime
sudo systemctl reload nginx
# Check cache status
curl -I https://example.com/api/products 2>/dev/null | grep X-Cache-Status
# Monitor access logs in real time
tail -f /var/log/nginx/access.json | jq .
Common Issues and Fixes
| Symptom | Likely Cause | Fix |
|---|---|---|
| 502 Bad Gateway | Backend is down or not listening | Verify backend is running on the expected port |
| 504 Gateway Timeout | Backend too slow | Increase proxy_read_timeout |
| 413 Request Entity Too Large | Upload exceeds limit | Increase client_max_body_size |
| WebSocket connections drop | Timeout too short | Set proxy_read_timeout 3600s for WS locations |
| Cache not working | Missing proxy_cache directive | Verify cache zone is defined and referenced |
| All cache MISS | Responses have Set-Cookie or Cache-Control: private | Backend headers prevent caching; adjust backend or use proxy_ignore_headers |
| Rate limiting too aggressive | Burst too low | Increase burst value |
| SSL handshake fails | Certificate path wrong or expired | Check with openssl s_client -connect host:443 |
Monitoring Key Metrics
Use the Nginx stub status module for basic metrics:
server {
listen 127.0.0.1:8080;
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
}
This exposes active connections, accepts, handled, and requests counters. For richer metrics, use the Nginx Prometheus exporter or the commercial Nginx Plus API.
Key Takeaways
- Use
upstreamblocks withkeepaliveconnections for dramatically better backend performance. Without keepalive, every request opens a new TCP connection. - Always set
proxy_set_header Host,X-Real-IP, andX-Forwarded-For. Your application needs accurate client information for logging, rate limiting, and security. - Enable response caching with
proxy_cache_use_staleandproxy_cache_background_updateto survive backend failures and serve fast responses during refresh cycles. - Apply rate limiting with
limit_req_zoneat different levels for different endpoints. Login pages need strict limits; static asset endpoints need looser ones. - Disable proxy buffering only for streaming endpoints (SSE, WebSocket, long-polling). Keep it enabled for everything else to free backend connections quickly.
- Use the
mapdirective for clean WebSocket upgrade handling. - Add
X-Cache-Statusheaders during development and keep them in production. They are essential for debugging caching behavior. - Tune compression level to 4 for the best CPU-to-ratio tradeoff. Enable gzip for all text-based content types.
- Run
nginx -tbefore every reload. A bad reload can take down your proxy. - Use JSON log format from day one. It makes log aggregation and analysis dramatically easier.
Nginx reverse proxy configuration is composable. Start with proxy_pass and add caching, rate limiting, SSL, and compression as your traffic demands. Each feature can be scoped to specific location blocks, giving you precise control over how each endpoint is handled. The result is a proxy layer that absorbs traffic spikes, protects your backends, and gives you deep visibility into your request flow.
SRE & Observability Engineer
If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.
Related Articles
Prometheus Recording Rules: Fix Your Query Performance Before It Breaks Grafana
Use Prometheus recording rules to pre-compute expensive queries, speed up dashboards, and make SLO calculations reliable at scale.
Security Headers & Configs: Cheat Sheet
Security headers and configuration reference — copy-paste snippets for Nginx, Kubernetes Ingress, Cloudflare, and Helmet.js.
Linux Performance Troubleshooting: CPU, Memory, Disk, and Network
Diagnose Linux performance bottlenecks with top, htop, vmstat, iostat, sar, iotop, and strace — a systematic approach to finding and fixing issues.