Skip to content

Reverse Proxy with Nginx

Table of Contents


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