Nginx Reverse Proxy: From Zero to Production

July 24, 2026

Every production web app needs something sitting in front of it. Nginx handles that job for roughly 34% of all websites. This guide covers the reverse proxy configs you'll actually use — not the exhaustive reference manual, but the patterns that ship to production.

Why Reverse Proxy?

Your app server (Node, Python, Go, Java) speaks HTTP but shouldn't face the internet directly. Nginx sits between clients and your app to handle things app servers are bad at:

Basic proxy_pass

The simplest reverse proxy config is five lines:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

This forwards every request to a local app on port 3000. But this bare config has problems — it doesn't forward headers, so your app sees every request as coming from 127.0.0.1. Fix that:

location / {
    proxy_pass http://127.0.0.1:3000;
    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;
}

Now your app knows the real client IP, the original Host header, and whether the request arrived over HTTP or HTTPS. Most frameworks trust these headers automatically when behind a proxy.

Trap: If your app uses Express.js, set app.set('trust proxy', true) or req.ip will always return 127.0.0.1.

SSL/TLS with Let's Encrypt

Certbot makes TLS setup almost automatic. Install it, run it, and it modifies your Nginx config for you:

# Install certbot
sudo apt install certbot python3-certbot-nginx

# Get certificate and auto-configure Nginx
sudo certbot --nginx -d example.com -d www.example.com

# Verify auto-renewal
sudo certbot renew --dry-run

Certbot adds the SSL directives and sets up a cron job for renewal. If you want to configure TLS manually (or understand what Certbot did), here's the production-ready block:

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;
    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;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    # Redirect HTTP to HTTPS
    add_header Strict-Transport-Security "max-age=63072000" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

# Redirect HTTP → HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

WebSocket Support

Standard proxy configs break WebSockets because Nginx buffers the connection by default. Add these two directives inside your location block:

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 "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400s;
    proxy_send_timeout 86400s;
}

The Upgrade and Connection headers tell Nginx to switch protocols instead of buffering. The 86400s timeout keeps long-lived WebSocket connections alive. Without it, idle connections drop after 60 seconds (Nginx default).

Load Balancing

When you have multiple app instances, use upstream to distribute traffic:

upstream backend {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    location / {
        proxy_pass http://backend;
        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;
    }
}

Nginx offers three balancing algorithms:

AlgorithmDirectiveBest For
Round Robin(default)Equal-capacity servers
Least Connectionsleast_conn;Varying request durations
IP Haship_hash;Session persistence needed

Add weight=N to send more traffic to stronger machines: server 127.0.0.1:3000 weight=3;

Caching

Nginx can cache responses from your app, reducing load dramatically for repeat requests:

# In http {} block
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:10m max_size=1g inactive=60m;

server {
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_cache app_cache;
        proxy_cache_valid 200 302 10m;
        proxy_cache_valid 404 1m;
        proxy_cache_use_stale error timeout updating;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

The X-Cache-Status header tells you if a response was HIT, MISS, or STALE. Use it during development — remove or keep it in production as you prefer.

Don't cache POST requests. Nginx won't by default, but if your app misuses GET for mutations, you'll serve stale data. Fix the app, not the cache config.

Rate Limiting

Protect your app from abuse by limiting request rates per IP:

# In http {} block
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=api_limit burst=20 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }
}

This allows 10 requests/second per IP with a burst buffer of 20. Excess requests get a 503. The nodelay flag serves burst requests immediately instead of queuing them.

Security Headers

Add these in your server block to harden against common attacks:

# Clickjacking protection
add_header X-Frame-Options "SAMEORIGIN" always;

# MIME-type sniffing prevention
add_header X-Content-Type-Options "nosniff" always;

# XSS filter (legacy but still useful)
add_header X-XSS-Protection "1; mode=block" always;

# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Content Security Policy — customize per app
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;

Test your headers: curl -sI https://example.com | grep -i "x-\|strict\|content-security"

Common Pitfalls

1. Trailing slash in proxy_pass

proxy_pass http://127.0.0.1:3000 forwards the full URI. proxy_pass http://127.0.0.1:3000/ strips the matched location prefix. This distinction causes more bugs than any other Nginx feature.

# Request: GET /api/users
# proxy_pass http://backend;       → forwards /api/users
# proxy_pass http://backend/;      → forwards /users  (prefix stripped)

2. Large file uploads fail

client_max_body_size 50m;    # In server or location block
proxy_request_buffering off;  # Stream uploads directly to backend

Default is 1MB. Every API that accepts file uploads needs this tuned.

3. Proxy buffers too small

If your backend returns large headers (big cookies, JWT tokens), Nginx returns 502:

proxy_buffer_size 16k;
proxy_buffers 4 32k;

Quick Reference

# Test config before reloading
sudo nginx -t

# Reload without dropping connections
sudo nginx -s reload

# Check which config files are loaded
nginx -T

# View access logs in real time
tail -f /var/log/nginx/access.log

# Check upstream health
curl -sI http://127.0.0.1:3000/health

Nginx reverse proxy is one of those tools that's simple to start with and deep enough to study for years. Start with basic proxy_pass, add TLS, then layer on caching and rate limiting as traffic grows. The config you need today is probably 20 lines. Ship that, and add complexity only when the monitoring data tells you to.