Node.js Application #

Node.js is a JavaScript runtime platform based on the V8 engine that runs single-threaded and asynchronously using an event loop. This characteristic makes Node.js very reliable for handling I/O-heavy traffic. However, to run Node.js applications (like Express, NestJS, Fastify, or Next.js) in high-level production environments, letting the Node.js process serve internet HTTP traffic directly is a dangerous anti-pattern.

Node.js processes aren’t designed to handle SSL/TLS termination efficiently, serve large static files, fend off DDoS attacks, or distribute workloads to other CPU cores. Therefore, we always place Nginx in front of Node.js applications as a high-level Reverse Proxy. In this article, we’ll discuss how to configure the Node.js reverse proxy optimally, apply static assets offloading tactics, manage custom file upload limits, and put together a load balancing scheme using the PM2 cluster.

Node.js Request Flow Architecture Behind Nginx #

Nginx acts as the front gate (shield) that receives HTTPS connections from outside clients, performs SSL decryption, serves static assets directly from the local filesystem (disk), and only forwards dynamic requests (like API requests) to the Node.js backend cluster.

Here’s a diagram of the request decision flow at Nginx before being forwarded to Node.js:

flowchart TD
    Client["Browser Client"] -->|"HTTPS (Port 443)"| Nginx["Nginx Reverse Proxy"]
    Nginx -->|"Check Static Files on Disk"| StaticCheck{"Is It a Static Asset File?"}
    StaticCheck -->|"Yes: JS/CSS/Images"| ServeStatic["Serve Directly from Disk /dist"]
    StaticCheck -->|"No: Dynamic/API Request"| ProxyPass["Forward the Request via Keepalive Upstream"]
    ProxyPass --> Upstream["Node.js Upstream Cluster (PM2)"]
    Upstream --> Node1["Node.js Instance 1 (Port 3000)"]
    Upstream --> Node2["Node.js Instance 2 (Port 3001)"]
    Upstream --> Node3["Node.js Instance 3 (Port 3002)"]

    classDef default fill:#f9f9f9,stroke:#d1d5db,stroke-width:1px,color:#111827;
    classDef nginxStyle fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a;
    classDef nodeStyle fill:#f0fdf4,stroke:#15803d,stroke-width:2px,color:#166534;
    class Nginx,ServeStatic nginxStyle;
    class Upstream,Node1,Node2,Node3 nodeStyle;

Reverse Proxy Header Configuration #

When Nginx forwards an HTTP request to Node.js using the proxy_pass directive, Nginx acts as a new client for Node.js. As a result, the real client IP address is lost and replaced by Nginx’s localhost IP (127.0.0.1). Likewise for the connection scheme (HTTP vs HTTPS).

To prevent this context loss, we must forward several standard proxy headers so Node.js can recognize the real client identity:

location / {
    proxy_pass http://localhost:3000;

    # 1. Forward the original Host header from the client browser
    proxy_set_header Host $host;

    # 2. Forward the real client IP to the backend
    proxy_set_header X-Real-IP $remote_addr;

    # 3. Forward the list of intermediary IPs (if passing through layered proxies)
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # 4. Tell the backend which protocol scheme is used (http or https)
    proxy_set_header X-Forwarded-Proto $scheme;
}

Configuration on the Node.js Application Side #

After Nginx forwards the headers above, we must configure our Node.js application to be willing to trust those proxy headers. Otherwise, the IP tracking functions or secure session cookies (HTTPS-only) in our Node.js won’t work.

  • On Express.js:
    const express = require('express');
    const app = express();
    
    // Enable trust proxy in Express
    app.enable('trust proxy');
    
    app.get('/api/ip', (req, res) => {
        // req.ip now contains the real client IP forwarded by Nginx
        res.json({ client_ip: req.ip, protocol: req.protocol });
    });
    
  • On NestJS:
    const app = await NestFactory.create<NestExpressApplication>(AppModule);
    // NestJS uses Express in the background by default
    app.set('trust proxy', true);
    

TCP Keepalive Optimization for Upstream Connections #

By default, Nginx closes the TCP connection to the Node.js backend as soon as the HTTP response finishes being sent to the client. This behavior is very inefficient for high-traffic applications, because our server CPU gets burdened doing repeated TCP handshakes.

We must configure connection pooling (Keepalive) so the TCP connections between Nginx and Node.js stay open in memory.

upstream nodejs_backend {
    server 127.0.0.1:3000;
    
    # Maintain a maximum of 32 idle connections open to the backend
    keepalive 32;
}

server {
    listen 80;
    server_name app.unisbadri.com;

    location / {
        proxy_pass http://nodejs_backend;

        # Required: Use HTTP/1.1 (the Nginx proxy default is HTTP/1.0, which doesn't support Keepalive)
        proxy_http_version 1.1;

        # Required: Empty the client Connection header so Nginx doesn't close the connection
        proxy_set_header Connection "";

        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;
    }
}

Static Assets Offloading: Eliminating the Node.js Load #

One of the biggest optimizations we can give the server is Static Offloading. Node.js is very slow and CPU-hungry when reading static files (like image files, fonts, CSS, compiled JS) from disk and streaming them to clients. Nginx, on the other hand, is written in low-level C and integrates with the Linux kernel’s sendfile syscall for super-fast zero-copy data transfer.

We configure Nginx to serve all static assets directly from disk, never forwarding those requests to the Node.js runtime.

server {
    listen 80;
    server_name app.unisbadri.com;

    # The output directory location of our frontend build (e.g., dist/ from the Vite build)
    root /var/www/my-node-app/dist;

    # 1. Static asset location (JS, CSS, Images, Fonts)
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        # Serve directly from the root directory above
        try_files $uri =404;

        # Enable aggressive browser caching for 1 year
        expires 1y;
        add_header Cache-Control "public, immutable";
        
        access_log off; # Turn off the access log so it doesn't dirty the disk
    }

    # 2. Dynamic request location (API or dynamic page routing)
    location / {
        proxy_pass http://nodejs_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }
}

Managing Client File Upload Limits #

If our Node.js application has a file upload feature (like profile picture or document uploads), we must set the upload size limit in Nginx. By default, Nginx limits the maximum request body size to 1 Megabyte. If a client tries to upload a file above 1MB, Nginx immediately cuts the connection and returns the 413 Request Entity Too Large error.

Here’s the tuning configuration for safe large file uploads:

server {
    listen 80;
    server_name app.unisbadri.com;

    location /api/upload/ {
        proxy_pass http://nodejs_backend;
        
        # 1. Raise the maximum file upload limit to 50 Megabytes
        client_max_body_size 50m;

        # 2. Adjust the RAM buffer allocation for the request body
        # Requests under 256KB are stored in RAM, above that they're written to a temp file on disk
        client_body_buffer_size 256k;

        # 3. Raise the timeout if clients upload over slow networks
        client_body_timeout 120s;
        
        # 4. Raise the timeout waiting for the backend to process large files
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
    }
}

Upstream Load Balancing with the PM2 Cluster #

Node.js runs on a single CPU core. To maximize multi-core CPU usage on our production server, we use a process manager like PM2 in Cluster mode. PM2 creates several instances of our Node.js application process based on the number of available CPU cores.

For example, if our server has 4 CPU cores, PM2 can be configured to run 4 Node.js processes listening on different ports (e.g., ports 3000, 3001, 3002, and 3003) or let PM2 do internal port sharing.

At the Nginx level, we configure upstream load balancing to evenly split the request load among all those Node.js instances:

# Upstream load balancing configuration
upstream pm2_node_cluster {
    # Distribute the request load to our 4 PM2 process instance ports
    server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3002 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;

    # Use keepalive for upstream connections
    keepalive 64;
}

server {
    listen 80;
    server_name app.unisbadri.com;

    location / {
        # Forward the traffic load to the upstream cluster above
        proxy_pass http://pm2_node_cluster;

        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }
}

Zero-Downtime Deployment Strategy with PM2 Reload #

When updating application code on the production server, abruptly killing the Node.js process (pm2 restart) will cut active client connections and cause 502 Bad Gateway errors on the Nginx side for a few seconds before the application turns back on.

To achieve zero-downtime deployment, we must combine the PM2 Graceful Reload mechanism with fault tolerance configuration in Nginx.

  1. Use pm2 reload (not restart): pm2 reload restarts instances one by one in rotation. New instances are started first before old instances are shut down.

  2. Configure Nginx Failover: We configure the proxy_next_upstream directive so Nginx automatically switches requests to other Node.js instances if one instance is being shut down or isn’t responding during the reload process:

upstream pm2_node_cluster {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3002 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
    keepalive 64;
}

server {
    # ...
    location / {
        proxy_pass http://pm2_node_cluster;
        
        # Switch requests to another upstream if the backend returns errors
        proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
        proxy_next_upstream_tries 3;
        proxy_next_upstream_timeout 5s;
        
        # ... other proxy_set_header configurations ...
    }
}

With the settings above, when PM2 shuts down one Node.js instance for reload, Nginx detecting the connection failure or 502 error immediately throws that request to the next instance in the upstream group without triggering errors in the client browser.


Complete Production Server Block Configuration Example (Node.js/Next.js) #

Here’s a complete production-level HTTPS server block configuration file combining SSL optimization, Gzip compression, static offloading, file upload tuning, and keepalive connection pooling:

# Backend upstream cluster definition
upstream nodejs_prod_cluster {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
    
    keepalive 32;
}

# Automatic HTTP to HTTPS redirection
server {
    listen 80;
    listen [::]:80;
    server_name app.unisbadri.com;
    
    return 301 https://$host$request_uri;
}

# Main HTTPS Server Block
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name app.unisbadri.com;

    # SSL Certificate Configuration (Let's Encrypt)
    ssl_certificate /etc/letsencrypt/live/app.unisbadri.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.unisbadri.com/privkey.pem;
    
    # SSL Parameter Hardening
    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 Caching
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # Static Asset Root Location (Next.js public / build assets)
    root /var/www/my-next-app/.next;

    # Next.js Static Asset Offloading (_next/static/)
    location /_next/static/ {
        alias /var/www/my-next-app/.next/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # General Asset File Offloading (public/ folder)
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2|webp)$ {
        root /var/www/my-next-app/public;
        try_files $uri =404;
        expires 30d;
        add_header Cache-Control "public, no-transform";
        access_log off;
    }

    # Special Large File Upload Endpoint
    location /api/upload/ {
        proxy_pass http://nodejs_prod_cluster;
        
        # 100MB file upload limit
        client_max_body_size 100m;
        client_body_buffer_size 512k;
        client_body_timeout 180s;
        
        proxy_read_timeout 180s;
        proxy_send_timeout 180s;

        # HTTP/1.1 reverse proxy configuration
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }

    # General / Dynamic Request Handling
    location / {
        proxy_pass http://nodejs_prod_cluster;
        
        # Default upload limit for regular requests (1 Megabyte)
        client_max_body_size 1m;

        # Standard reverse proxy configuration
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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 Buffering Handling
        proxy_buffering on;
        proxy_buffers 16 16k;
        proxy_buffer_size 32k;
    }
}

Common Integration Troubleshooting Table #

Here’s a list of common errors that often appear when connecting Nginx with Node.js along with their solution steps:

Error CodeProblem SymptomField CausePractical Solution
502 Bad GatewayNginx failed to forward the request. The page shows a 502 error.The Node.js application is dead, PM2 crashed, or the backend port is wrong.Check PM2 status with pm2 list or run netstat -plnt to verify the port.
504 Gateway TimeoutNginx cut the connection before Node.js finished processing.Node.js is processing a heavy task (like processing a large database) beyond the timeout limit.Raise the duration of the proxy_read_timeout directive in Nginx to a higher value.
413 Request Entity Too LargeThe client failed to upload a file.The request body size exceeds the default 1MB limit.Add the client_max_body_size 50m; directive (per the needed limit) inside the location block.
404 Not FoundStatic files don’t appear in the client browser.The folder path in the Nginx root or alias directive is pointed wrong or doesn’t have read access rights.Verify the alignment of the custom folder path and check access rights using chmod / chown.

Summary and Best Practices #

  • Always Enable trust proxy: Make sure our Node.js backend application has enabled the trust proxy setting so client IP address and connection protocol (HTTP/HTTPS) reads from the Node.js server log are accurate.
  • Do Static Asset Offloading: Letting Node.js serve static files wastes valuable event loop work cycles. Always use Nginx to serve static files directly from disk.
  • Use PM2 Cluster Mode: Utilize all the physical CPU cores on our server by turning on PM2 cluster mode so our backend has high scalability and availability (high availability).
  • Test the Configuration Before Reload: Always run sudo nginx -t before reloading or restarting Nginx so we don’t cut running production server connections due to syntax typos.

← Previous: Dynamic Module   Next: PHP-FPM (Laravel/WordPress) →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact