Skip to content

Reverse Proxy with Nginx

Node.js is great at handling requests but terrible at serving static files and managing TLS Let Nginx handle the boring stuff - TLS termination , static files , load balancing , rate limiting - while Node focuses on what it does best Your Node process shouldn't parse a TLS handshake or serve a logo image when Nginx does both at 10x the speed

Why You Need a Reverse Proxy

A reverse proxy sits in front of your Node app and handles the edge traffic

  • TLS termination - Nginx handles HTTPS , Node gets plain HTTP No certificate logic in your app , no OpenSSL pain , Certbot auto-renewal
  • Load balancing - distribute traffic across multiple Node processes
  • Static file serving - Nginx serves files from disk at kernel speed Node.js serving a 5MB image ties up an event loop tick for milliseconds
  • Compression - Nginx gzips responses without CPU overhead on your app
  • Rate limiting - block abusive traffic before it touches Node
  • Header security - add security headers at the proxy layer

Every production Node.js app needs a reverse proxy
If you're exposing Node's port directly to the internet , fix that

Basic Nginx Configuration

# /etc/nginx/sites-available/myapp
upstream myapp_nodes {
    least_conn;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
    server 127.0.0.1:3004;
}

server {
    listen 80;
    server_name api.myapp.com;
    return 301 https://$server_name$request_uri;  # force HTTPS
}

server {
    listen 443 ssl http2;
    server_name api.myapp.com;

    ssl_certificate /etc/letsencrypt/live/api.myapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.myapp.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    # Hide Nginx version
    server_tokens off;

    # Static files - served directly , bypassing Node
    location /static/ {
        alias /var/www/myapp/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Health check endpoint - no rate limit
    location /healthz {
        proxy_pass http://myapp_nodes;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        access_log off;  # don't fill logs with health checks
    }

    # API traffic
    location / {
        proxy_pass http://myapp_nodes;
        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;

        # Timeouts - don't let slow clients hold connections
        proxy_connect_timeout 5s;
        proxy_send_timeout 10s;
        proxy_read_timeout 30s;

        # Buffer sizes
        proxy_buffering off;
        proxy_buffer_size 4k;
        proxy_busy_buffers_size 8k;

        # Rate limit - 100 req/min per IP
        limit_req zone=api_limit burst=20 nodelay;
    }
}

TLS Termination with Let's Encrypt

Certbot automates certificate generation and renewal

# Install Certbot with Nginx plugin
sudo apt install certbot python3-certbot-nginx

# Get certificate
sudo certbot --nginx -d api.myapp.com

# Auto-renewal (Certbot adds a systemd timer automatically)
sudo certbot renew --dry-run

Certbot modifies your Nginx config to add the SSL directives - review the changes before applying on production

TLS best practices: - Use TLS 1.2 and 1.3 only - disable TLS 1.0 and 1.1 (they're compromised) - Set HSTS header to enforce HTTPS for a year - Use a valid certificate from Let's Encrypt or a commercial CA - self-signed certs break API clients - Set up OCSP Stapling to improve TLS handshake performance

# OCSP Stapling - clients don't need to check revocation separately
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/api.myapp.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

Load Balancing Upstreams

Nginx distributes requests across the upstream server group

# Round-robin (default) - distributes evenly
upstream myapp_nodes {
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}

# Least connections - sends to least busy server
upstream myapp_nodes {
    least_conn;
    server 127.0.0.1:3001 weight=2;  # higher weight = more traffic
    server 127.0.0.1:3002;
    server 127.0.0.1:3003 max_fails=3 fail_timeout=30s;
}

# IP hash - session persistence (same client hits same server)
upstream myapp_nodes {
    ip_hash;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}
# PM2 cluster mode spawns processes
pm2 start app.js -i 4 --name myapp-api

# Verify ports
pm2 show myapp-api | grep PORT
# Or check each process
pm2 prettylist | grep port

For socket-based communication instead of port-based:

upstream myapp {
    server unix:/tmp/myapp-0.sock;
    server unix:/tmp/myapp-1.sock;
    server unix:/tmp/myapp-2.sock;
}

Static File Serving Through Nginx

Node.js serving static files is burning CPU on I/O wait
Nginx does it at kernel level with sendfile

location /static/ {
    alias /var/www/myapp/public/;
    expires 30d;
    add_header Cache-Control "public, immutable";
    add_header Access-Control-Allow-Origin "*";

    # Direct file access - no routing , no middleware
    try_files $uri =404;

    # Deny access to hidden files
    location ~ /\. {
        deny all;
        return 404;
    }
}

What to serve through Nginx: - Images , CSS , JS bundles , fonts - Downloads , PDFs , static documentation - Uploaded user files (with proper access controls)

What NOT to serve through Nginx: - Dynamic API responses (that's Node's job) - Files requiring authentication (unless you configure Nginx auth subrequest) - Temporary or generated content

Rate Limiting at Nginx Level

Stop brute force and DDoS before they reach Node

# Define rate limit zones - shared memory between nginx workers
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
limit_req_zone $http_x_forwarded_for zone=strict_limit:10m rate=30r/m;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

# Apply to locations
location /api/ {
    limit_req zone=api_limit burst=20 nodelay;
    limit_conn conn_limit 10;
    proxy_pass http://myapp_nodes;
}

location /api/login {
    limit_req zone=login_limit burst=3 nodelay;
    proxy_pass http://myapp_nodes;
}

# Return 429 on rate limit
# /var/log/nginx/rate_limited.log
# Rate limit response customization
location /api/ {
    limit_req zone=api_limit burst=20 nodelay;
    limit_req_status 429;
    limit_req_log_level warn;

    # Custom error page for rate-limited requests
    error_page 429 /rate-limited.html;
    proxy_pass http://myapp_nodes;
}

Rate limit logging helps tune thresholds - check /var/log/nginx/error.log for limiting requests entries If legit users hit limits constantly , raise the rate or increase burst

Nginx Security Hardening

A misconfigured Nginx is just another attack surface

# /etc/nginx/nginx.conf - global settings
user nginx;
worker_processes auto;
pid /run/nginx.pid;

events {
    worker_connections 2048;
    multi_accept on;
    use epoll;
}

http {
    # Hide Nginx version from attackers
    server_tokens off;

    # Limit request body size - prevent large payload attacks
    client_max_body_size 10M;

    # Buffer size limits - prevent buffer overflow attacks
    client_body_buffer_size 128k;
    client_header_buffer_size 1k;
    large_client_header_buffers 4 8k;

    # Timeout settings - close slow connections
    client_body_timeout 10s;
    client_header_timeout 10s;
    send_timeout 10s;
    keepalive_timeout 30s;
    keepalive_requests 1000;

    # Log format with security-relevant fields
    log_format security '$remote_addr - $remote_user [$time_local] '
                       '"$request" $status $body_bytes_sent '
                       '"$http_referer" "$http_user_agent" '
                       '$request_time $upstream_addr';

    access_log /var/log/nginx/access.log security;
    error_log /var/log/nginx/error.log warn;
}
# Restrict access paths - block sensitive endpoints
location ~ /\. {
    deny all;
    return 404;
}

location ~ /node_modules {
    deny all;
    return 404;
}

location ~ /\.env {
    deny all;
    return 404;
}

location ~* (\.bak|\.config|\.sql|\.swp)$ {
    deny all;
    return 404;
}

Security checklist: - server_tokens off - don't leak Nginx version - add_header X-Frame-Options "SAMEORIGIN" - prevent clickjacking - add_header X-Content-Type-Options "nosniff" - prevent MIME sniffing - add_header Strict-Transport-Security - enforce HTTPS - client_max_body_size 10M - prevent large payload attacks - Block access to dotfiles , node_modules , and backup files - Use deny all on internal-only endpoints

Prerequisites


next -> deploy_05_monitoring.md