Nginx Reverse Proxy Guide 2026 — Load Balancing, SSL, and Rate Limiting

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Nginx handles 34% of all web server traffic. As a reverse proxy in front of Node.js, it handles SSL termination, caching of static files, rate limiting, gzip compression, and load balancing — all without changing application code. In 2026, the combination of Nginx + Let's Encrypt + Node.js remains the go-to production stack for self-hosted applications.

Installation

sudo apt update
sudo apt install nginx
sudo systemctl enable nginx
sudo systemctl start nginx
 
# Test config before reloading
sudo nginx -t
sudo systemctl reload nginx

Reverse Proxy for Node.js

# /etc/nginx/sites-available/myapp
upstream nodejs_app {
    server 127.0.0.1:3000;
    keepalive 64;
}
 
server {
    listen 80;
    server_name myapp.com www.myapp.com;
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl http2;
    server_name myapp.com www.myapp.com;
 
    ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
 
    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;
 
    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript
               text/xml application/xml image/svg+xml;
 
    client_max_body_size 10M;
 
    location / {
        proxy_pass http://nodejs_app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header 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_cache_bypass $http_upgrade;
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
 
    # Serve Next.js static files directly from Nginx (no Node.js hop)
    location /_next/static/ {
        alias /app/.next/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
 
    location /public/ {
        alias /app/public/;
        expires 30d;
    }
}

Enable the site:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Load Balancing Across Multiple Servers

upstream api_servers {
    least_conn;   # Route to server with fewest active connections
 
    server 10.0.1.10:3000 weight=3;   # Gets 3x more traffic
    server 10.0.1.11:3000 weight=1;
    server 10.0.1.12:3000 backup;     # Only used when others fail
 
    keepalive 32;
}
 
server {
    listen 443 ssl http2;
 
    location /api/ {
        proxy_pass http://api_servers;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
    }
}

Load balancing methods:

MethodDirectiveBest For
Round robin(default)Equal servers
Least connectionsleast_connLong requests
IP haship_hashSession affinity
Weightedweight=NMixed capacity

Rate Limiting

http {
    # Define rate limit zones
    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
    limit_conn_zone $binary_remote_addr zone=conn:10m;
 
    server {
        location /api/ {
            limit_req zone=api burst=10 nodelay;
            limit_conn conn 10;
            limit_req_status 429;
            proxy_pass http://nodejs_app;
        }
 
        location /api/auth/login {
            limit_req zone=login burst=3 nodelay;
            limit_req_status 429;
            proxy_pass http://nodejs_app;
        }
 
        error_page 429 /429.json;
        location = /429.json {
            default_type application/json;
            return 429 '{"error":"Too many requests","retryAfter":60}';
        }
    }
}

Let's Encrypt SSL (Free)

# Install Certbot
sudo apt install certbot python3-certbot-nginx
 
# Obtain and auto-configure SSL
sudo certbot --nginx -d myapp.com -d www.myapp.com
 
# Test auto-renewal
sudo certbot renew --dry-run
 
# Manual renewal cron (certbot auto-adds this)
0 12 * * * /usr/bin/certbot renew --quiet

Static File Server for SPAs

server {
    listen 80;
    server_name static.myapp.com;
    root /var/www/static;
 
    # SPA fallback — serve index.html for all routes
    location / {
        try_files $uri $uri/ /index.html;
    }
 
    # Aggressive caching for hashed assets
    location ~* \.(js|css|png|jpg|ico|woff2|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
 
    # No caching for HTML
    location ~* \.html$ {
        expires -1;
        add_header Cache-Control "no-store";
    }
}

Performance Tuning

# /etc/nginx/nginx.conf
worker_processes auto;       # Match CPU core count
worker_connections 1024;
 
events {
    use epoll;               # Fastest event model on Linux
    multi_accept on;
}
 
http {
    sendfile on;             # Efficient static file serving
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    keepalive_requests 100;
 
    # Buffer sizing
    client_body_buffer_size 16k;
    client_header_buffer_size 1k;
    client_max_body_size 10m;
 
    # Access logs with timing
    log_format main '$remote_addr - [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '$request_time';
    access_log /var/log/nginx/access.log main buffer=16k;
    error_log  /var/log/nginx/error.log warn;
}

Common Mistakes

  • No nginx -t before reloading — always validate config before applying; a syntax error will stop Nginx from reloading
  • Missing WebSocket headers — without Upgrade and Connection: upgrade headers, WebSocket connections fail silently
  • Too-small client_max_body_size — the default 1MB limit causes file upload failures; set it to match your app's max upload
  • No rate limiting on auth endpoints — login routes without rate limits are vulnerable to brute-force attacks
  • Serving Node.js static assets through Nginx without caching — always set immutable cache headers on hashed _next/static/ files

Best Practices

  • Always redirect HTTP to HTTPS with a 301 permanent redirect — never serve over HTTP in production
  • Use ssl_session_cache shared:SSL:10m to enable TLS session resumption and reduce handshake overhead
  • Serve static files (images, CSS, JS) directly from Nginx using alias — eliminates unnecessary Node.js overhead
  • Enable gzip compression for text-based content — reduces transfer size by 60-80% for HTML, JSON, and CSS
  • Monitor nginx/error.log and nginx/access.log — they are the first place to look for 502, 503, and 504 errors

Key Takeaways

  • Nginx as a reverse proxy handles SSL, compression, rate limiting, and static serving without touching application code
  • upstream blocks with keepalive enable persistent connections from Nginx to Node.js, reducing per-request overhead
  • least_conn is the best default load balancing method when requests have variable response times
  • Rate limit zones use shared memory (10m) to track request counts across all worker processes
  • Let's Encrypt SSL is free, auto-renewed by Certbot, and takes under 2 minutes to configure via the --nginx plugin
  • try_files $uri $uri/ /index.html is the correct pattern for SPA routing — it falls back to index.html for all unmatched routes
  • sendfile on + tcp_nopush on enables kernel-level file sending that bypasses user space for static file serving
  • Setting expires 1y with Cache-Control: immutable on content-hashed assets eliminates repeat downloads entirely

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading