DevOpsil
Nginx
92%
Fresh

Nginx Reverse Proxy & Caching: The Complete Guide

Riku TanakaRiku Tanaka23 min read

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

MethodDirectiveBehaviorBest For
Round Robin(default)Distributes requests sequentially, respects weightsGeneral purpose, stateless apps
Least Connectionsleast_connRoutes to server with fewest active connectionsVariable request duration, long-running requests
IP Haship_hashConsistent mapping of client IP to serverSession affinity without cookies
Generic Hashhash $request_uri consistentConsistent hashing on any variableCache distribution, content-based routing
Randomrandom two least_connPicks two random servers, chooses the one with fewer connectionsLarge 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:

HeaderPurposeUsed By
HostOriginal hostname from the client requestBackend virtual hosting, routing
X-Real-IPDirect client IP addressApplication logging, geo-IP
X-Forwarded-ForChain of all proxy IPs the request passed throughAudit trails, rate limiting at app level
X-Forwarded-ProtoWhether the client used HTTP or HTTPSRedirect logic, secure cookie decisions
X-Request-IDUnique identifier for the requestDistributed 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:

OptimizationDirectiveImpact
Session cachessl_session_cache shared:SSL:50mAvoids full handshake for returning clients. 50MB holds ~200,000 sessions.
Session timeoutssl_session_timeout 1dSessions valid for 24 hours
OCSP staplingssl_stapling onClient does not need to contact CA for revocation check
HTTP/2listen 443 ssl http2Multiplexes requests over a single connection
Early data (TLS 1.3)ssl_early_data on0-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:

ParameterPurposeSizing Guidance
levels=1:2Directory hashing structure (one char, then two chars)Standard value, no need to change
keys_zone=app_cache:20mShared memory for cache keys. 1MB holds ~8,000 keys.10m for small sites, 50m+ for millions of URLs
max_size=2gMaximum disk space for cached contentSize based on available disk and content volume
inactive=60mRemove entries not accessed in 60 minutesMatch to your content change frequency
use_temp_path=offWrite directly to cache directoryAlways set off to reduce I/O overhead
min_free=256mMinimum free disk space to maintainPrevents 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:

DirectivePurpose
proxy_cache_valid 200 10mCache 200 responses for 10 minutes
proxy_cache_min_uses 2Only cache after 2 requests (avoids caching one-off URLs)
proxy_cache_use_staleServe stale content when backend is down or slow
proxy_cache_background_updateRefresh stale content in the background while serving old version
proxy_cache_lockWhen multiple requests hit the same uncached URL simultaneously, only one goes to the backend. Others wait for the cache to fill.
proxy_cache_lock_timeoutHow 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:

StatusMeaning
HITResponse served from cache
MISSResponse not in cache, fetched from backend
EXPIREDCache entry expired, fetched fresh from backend
STALEServed expired content because backend is unavailable
UPDATINGStale content served while background update runs
REVALIDATEDBackend confirmed cache entry is still valid (304)
BYPASSCache 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:

ParameterEffect
rate=10r/sSustained rate of 10 requests per second per IP
burst=20Allow up to 20 excess requests to queue
nodelayProcess burst requests immediately instead of spacing them out
delay=10Process 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.

ScenarioBufferingReason
Standard API requestsOnFrees backend connections quickly
File downloadsOnBackend is released while slow clients download
Server-Sent Events (SSE)OffEvents must arrive in real time
Streaming responsesOffContent must flow continuously
Long-pollingOffConnection must stay open
gRPC streamingOffBidirectional 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

ConfigurationRequests/sec (approx)Notes
Default Nginx config10,000-15,000Out of the box, no tuning
Tuned workers + keepalive25,000-40,000Worker count, keepalive to backends
With proxy caching (HIT)50,000-100,000+Cached responses bypass backend entirely
Static file serving100,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

SymptomLikely CauseFix
502 Bad GatewayBackend is down or not listeningVerify backend is running on the expected port
504 Gateway TimeoutBackend too slowIncrease proxy_read_timeout
413 Request Entity Too LargeUpload exceeds limitIncrease client_max_body_size
WebSocket connections dropTimeout too shortSet proxy_read_timeout 3600s for WS locations
Cache not workingMissing proxy_cache directiveVerify cache zone is defined and referenced
All cache MISSResponses have Set-Cookie or Cache-Control: privateBackend headers prevent caching; adjust backend or use proxy_ignore_headers
Rate limiting too aggressiveBurst too lowIncrease burst value
SSL handshake failsCertificate path wrong or expiredCheck 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 upstream blocks with keepalive connections for dramatically better backend performance. Without keepalive, every request opens a new TCP connection.
  • Always set proxy_set_header Host, X-Real-IP, and X-Forwarded-For. Your application needs accurate client information for logging, rate limiting, and security.
  • Enable response caching with proxy_cache_use_stale and proxy_cache_background_update to survive backend failures and serve fast responses during refresh cycles.
  • Apply rate limiting with limit_req_zone at 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 map directive for clean WebSocket upgrade handling.
  • Add X-Cache-Status headers 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 -t before 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.

Share:
Riku Tanaka
Riku Tanaka

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