MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Nginx Reverse Proxy Configuration for Node.js

Configure Nginx as a reverse proxy: SSL termination, load balancing, and caching.

NginxReverse ProxyNode.jsDevOps

By MinhVo

Introduction

Running a Node.js application directly on port 80 or 443 is a security and operational anti-pattern. Node.js processes can crash, restart, and need to be managed by a process manager. They are not optimized for serving static files, handling SSL termination, or managing thousands of concurrent connections efficiently. This is exactly where Nginx excels.

By placing Nginx in front of your Node.js application, you create a robust production architecture where Nginx handles all the networking concerns and your Node.js application focuses purely on business logic. Nginx terminates SSL connections, serves static files at wire speed, compresses responses, load balances across multiple Node.js instances, and provides a buffer against traffic spikes.

This guide walks through every aspect of configuring Nginx as a reverse proxy specifically for Node.js applications, from initial setup to production-hardened configurations with SSL, WebSocket support, and performance tuning.

Node.js and Nginx architecture

Understanding the Nginx-Node.js Relationship: Core Concepts

Why You Need a Reverse Proxy

A Node.js application running with Express, Fastify, or any other framework listens on a single port and handles HTTP requests in its event loop. While Node.js is excellent at handling concurrent I/O operations, it has several limitations that make it unsuitable as a direct internet-facing server.

First, Node.js runs as a single process by default. If an unhandled exception crashes the process, your application goes down. You need a process manager like PM2 to restart it, but that introduces a brief downtime window. Nginx can detect the backend failure and return a maintenance page or retry the request.

Second, Node.js does not efficiently serve static files. Reading files from disk and streaming them through the event loop is significantly slower than Nginx optimized static file serving, which uses the sendfile system call to transfer files directly from the kernel buffer to the network socket.

Third, SSL termination is computationally expensive. Offloading TLS encryption to Nginx frees your Node.js processes to handle application logic instead of cryptographic operations.

The Architecture

The standard production architecture places Nginx on the edge of your infrastructure. It listens on ports 80 and 443, terminates SSL, and proxies requests to one or more Node.js instances running on localhost on high ports (3000, 3001, 3002, etc.).

Client (HTTPS) -> Nginx (:443) -> Node.js (:3000)
                                -> Node.js (:3001)
                                -> Node.js (:3002)

Nginx handles everything related to the network: connection management, SSL, compression, caching, rate limiting, and security headers. Your Node.js application receives plain HTTP requests on localhost and focuses on business logic.

Process Management with PM2

PM2 is the standard process manager for Node.js in production. It manages multiple instances of your application, restarts crashed processes, and provides monitoring capabilities.

# Install PM2
npm install -g pm2
 
# Start 4 instances of your app (one per CPU core)
pm2 start app.js -i max --name "my-app"
 
# PM2 configuration file: ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-app',
    script: 'app.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    max_memory_restart: '1G',
    error_file: '/var/log/pm2/error.log',
    out_file: '/var/log/pm2/out.log',
    merge_logs: true
  }]
};

Server infrastructure diagram

Architecture and Design Patterns

Single Instance Setup

For development or small applications, a single Node.js instance behind Nginx is sufficient.

server {
    listen 80;
    server_name example.com;
    
    location / {
        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_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;
    }
}

Multi-Instance Load Balancing

For production applications, run multiple Node.js instances and let Nginx distribute traffic across them.

upstream node_cluster {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
    keepalive 64;
}
 
server {
    listen 80;
    server_name example.com;
    
    location / {
        proxy_pass http://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;
    }
}

Mixed Static and Dynamic Content

The most performant setup separates static file serving from dynamic request proxying. Nginx serves static files directly from disk, while dynamic requests go to Node.js.

server {
    listen 80;
    server_name example.com;
    root /var/www/app/public;
    
    # Serve static files directly
    location /static/ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
        try_files $uri =404;
    }
    
    location /uploads/ {
        expires 7d;
        add_header Cache-Control "public";
        access_log off;
        try_files $uri =404;
    }
    
    # Favicon and robots
    location = /favicon.ico {
        access_log off;
        log_not_found off;
    }
    
    location = /robots.txt {
        access_log off;
        log_not_found off;
    }
    
    # Proxy everything else to Node.js
    location / {
        proxy_pass http://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;
    }
}

Step-by-Step Implementation

SSL Configuration for Node.js

Production applications require HTTPS. Here is the complete SSL setup with Let Encrypt certificates.

server {
    listen 443 ssl http2;
    server_name example.com;
    
    # Let's Encrypt certificates
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    
    # Modern SSL settings
    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;
    
    # Session caching
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    
    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4 valid=300s;
    
    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    
    # Proxy to Node.js
    location / {
        proxy_pass http://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;
    }
}
 
# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

WebSocket Configuration

Many Node.js applications use WebSockets for real-time communication. Nginx needs special configuration to properly proxy WebSocket connections.

map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}
 
upstream websocket_backend {
    server 127.0.0.1:3001;
    keepalive 32;
}
 
server {
    listen 443 ssl http2;
    server_name example.com;
    
    # Regular HTTP traffic
    location / {
        proxy_pass http://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;
    }
    
    # WebSocket connections
    location /ws/ {
        proxy_pass http://websocket_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

Express.js Trust Proxy Configuration

When Nginx sits in front of Express, you must configure Express to trust the proxy headers. Without this, req.ip will always return the Nginx IP address instead of the real client IP.

// app.js
const express = require('express');
const app = express();
 
// Trust the first proxy (Nginx)
app.set('trust proxy', 1);
 
// Now req.ip returns the real client IP from X-Forwarded-For
app.get('/ip', (req, res) => {
  res.json({ ip: req.ip, forwarded: req.headers['x-forwarded-for'] });
});

Fastify has a similar configuration:

const fastify = require('fastify')({
  trustProxy: true
});

Production deployment workflow

Real-World Use Cases

Use Case 1: Express API Behind Nginx

A REST API built with Express that needs rate limiting, SSL, and logging.

upstream api_backend {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    keepalive 32;
}
 
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
 
server {
    listen 443 ssl http2;
    server_name api.example.com;
    
    ssl_certificate /etc/ssl/api.example.com.crt;
    ssl_certificate_key /etc/ssl/api.example.com.key;
    
    # Custom log format with timing
    log_format api_log '$remote_addr [$time_local] "$request" $status '
                       '$body_bytes_sent rt=$request_time '
                       'uct=$upstream_connect_time urt=$upstream_response_time';
    
    access_log /var/log/nginx/api.log api_log;
    
    location /auth/ {
        limit_req zone=auth burst=3 nodelay;
        proxy_pass http://api_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;
    }
    
    location /api/ {
        limit_req zone=api burst=50 nodelay;
        proxy_pass http://api_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;
    }
}

Use Case 2: Next.js Application

Next.js applications benefit from Nginx because Nginx can serve static assets from the .next/static directory and proxy server-rendered pages to the Next.js process.

upstream nextjs {
    server 127.0.0.1:3000;
    keepalive 32;
}
 
server {
    listen 443 ssl http2;
    server_name example.com;
    
    ssl_certificate /etc/ssl/example.com.crt;
    ssl_certificate_key /etc/ssl/example.com.key;
    
    # Serve Next.js static assets
    location /_next/static/ {
        alias /var/www/app/.next/static/;
        expires 365d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
    
    # Proxy to Next.js
    location / {
        proxy_pass http://nextjs;
        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;
    }
}

Use Case 3: Socket.io Real-Time Application

Socket.io uses both HTTP long-polling and WebSocket transports. Nginx must be configured to handle both.

upstream socketio {
    ip_hash;  # Required for Socket.io sticky sessions
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}
 
server {
    listen 443 ssl http2;
    server_name realtime.example.com;
    
    location /socket.io/ {
        proxy_pass http://socketio;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_buffering off;
        proxy_read_timeout 86400s;
    }
}

Best Practices for Production

  1. Always set proxy_http_version 1.1: HTTP 1.1 enables keep-alive connections to your Node.js backend, which is critical for performance. Without it, Nginx creates a new TCP connection for every request.

  2. Clear the Connection header with keepalive: When using upstream keepalive, set proxy_set_header Connection "" to prevent the client Connection header from being forwarded to the backend.

  3. Set trust proxy in Express: Without this, Express sees the Nginx IP as the client IP, breaking rate limiting, logging, and security features that depend on client IP.

  4. Use least_conn load balancing: Node.js request processing times vary significantly based on the operation. Least connections ensures even distribution.

  5. Serve static files from Nginx: Let Nginx serve CSS, JS, images, and other static assets. Use expires and Cache-Control headers for aggressive caching.

  6. Enable gzip compression at the Nginx level: Compress text responses (HTML, CSS, JS, JSON, XML) before they leave the server. Do not compress images or other already-compressed formats.

  7. Implement health check endpoints: Add a /health endpoint to your Node.js application that Nginx can check to determine backend availability.

  8. Use request IDs for distributed tracing: Generate a unique request ID in Nginx and pass it to your Node.js application for end-to-end request tracking.

# Generate and pass request ID
proxy_set_header X-Request-ID $request_id;
 
# In your Node.js app, access it via:
// req.headers['x-request-id']

Common Pitfalls and Solutions

PitfallImpactSolution
Not setting trust proxy in ExpressWrong client IP in logs and rate limitingAdd app.set('trust proxy', 1)
Missing WebSocket upgrade headersWebSocket connections fail silentlyAdd Upgrade and Connection headers for WS locations
No keepalive to upstreamNew TCP connection per request, high latencyUse keepalive in upstream block
Serving static files through Node.jsPoor performance under loadServe static files directly from Nginx
No health check endpointNginx sends traffic to crashed backendsImplement /health endpoint and use max_fails
Incorrect proxy_buffer_sizeLarge responses truncated or slowIncrease proxy_buffer_size for large API responses

Performance Optimization

Gzip Compression

http {
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/json
        application/javascript
        application/xml
        application/rss+xml
        application/atom+xml
        image/svg+xml;
}

Response Caching for Node.js APIs

proxy_cache_path /var/cache/nginx/api 
    levels=1:2 
    keys_zone=api_cache:10m 
    max_size=100m 
    inactive=60m;
 
server {
    location /api/products {
        proxy_pass http://node_cluster;
        proxy_cache api_cache;
        proxy_cache_valid 200 5m;
        proxy_cache_valid 404 1m;
        proxy_cache_use_stale error timeout updating;
        proxy_cache_lock on;
        
        # Only cache GET and HEAD requests
        proxy_cache_methods GET HEAD;
        
        # Don't cache requests with auth
        proxy_cache_bypass $http_authorization;
        
        add_header X-Cache-Status $upstream_cache_status;
    }
}

Connection Tuning

http {
    # Keepalive to clients
    keepalive_timeout 65;
    keepalive_requests 100;
    
    # Buffer sizes
    client_body_buffer_size 16k;
    client_header_buffer_size 1k;
    client_max_body_size 50m;
    large_client_header_buffers 4 8k;
    
    # Timeouts
    client_body_timeout 12;
    client_header_timeout 12;
    send_timeout 10;
}

Testing Strategies

# Test configuration syntax
sudo nginx -t
 
# Reload without downtime
sudo nginx -s reload
 
# Test the proxy
curl -H "Host: example.com" http://localhost/
curl -H "Host: example.com" http://localhost/api/health
 
# Verify SSL
openssl s_client -connect example.com:443 -servername example.com
 
# Load test with wrk
wrk -t12 -c400 -d30s https://example.com/
 
# Check upstream response time
curl -w "@curl-format.txt" -o /dev/null -s https://example.com/api/data

Future Outlook

Nginx continues to be the standard reverse proxy for Node.js applications. HTTP/3 support is maturing, and the Nginx Unit project provides an alternative to PM2 for process management. Container orchestration with Kubernetes often uses Nginx Ingress Controller, which brings Nginx reverse proxy capabilities to the container networking layer.

The rise of edge computing platforms like Cloudflare Workers and Deno Deploy is changing where server-side JavaScript runs, but for traditional server deployments, the Nginx-plus-Node.js combination remains the gold standard.

Production Deployment and Operations

Running backend services in production requires attention to reliability, observability, and operational concerns that don't exist in development environments. Proper deployment practices ensure your service remains available and performant under real-world conditions.

Graceful Shutdown Handling

Implement graceful shutdown to prevent request failures during deployments and restarts:

const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
 
async function gracefulShutdown(signal) {
  console.log(`Received ${signal}, starting graceful shutdown...`);
 
  // Stop accepting new connections
  server.close(async () => {
    console.log('HTTP server closed');
 
    try {
      // Wait for existing requests to complete (with timeout)
      await Promise.race([
        waitForActiveRequests(),
        new Promise((_, reject) =>
          setTimeout(() => reject(new Error('Shutdown timeout')), 30000)
        ),
      ]);
 
      // Close database connections
      await db.destroy();
      await redis.quit();
 
      console.log('Graceful shutdown completed');
      process.exit(0);
    } catch (error) {
      console.error('Error during shutdown:', error);
      process.exit(1);
    }
  });
 
  // Force shutdown after timeout
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 35000);
}
 
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Structured Logging

Replace console.log with structured logging that supports log aggregation and querying:

const pino = require('pino');
 
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level(label) {
      return { level: label };
    },
  },
  serializers: {
    err: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie'],
    remove: true,
  },
});
 
// Request logging middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      req,
      res,
      responseTime: Date.now() - start,
    }, `${req.method} ${req.url} ${res.statusCode}`);
  });
  next();
});

Rate Limiting and Abuse Prevention

Protect your API endpoints with rate limiting that adapts to different client types:

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
 
const apiLimiter = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) => req.user?.id || req.ip,
  handler: (req, res) => {
    logger.warn({ ip: req.ip, user: req.user?.id }, 'Rate limit exceeded');
    res.status(429).json({
      error: 'Too many requests',
      retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
    });
  },
});
 
app.use('/api/', apiLimiter);

These operational practices form the foundation of a reliable production service that can handle real-world traffic patterns and failure scenarios.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Nginx reverse proxy configuration for Node.js is not optional in production. It is a fundamental requirement for security, performance, and reliability. The combination of Nginx handling network concerns and Node.js handling application logic creates a clean separation of responsibilities that scales from a single server to a multi-server cluster.

Key takeaways:

  1. Always run Node.js behind Nginx in production
  2. Use PM2 with cluster mode for multi-instance Node.js
  3. Configure trust proxy in Express to get correct client IPs
  4. Serve static files from Nginx, not Node.js
  5. Set up WebSocket proxying with proper upgrade headers
  6. Enable gzip compression and response caching at the Nginx layer
  7. Implement rate limiting to protect your backend
  8. Use SSL termination at Nginx with Let Encrypt certificates

Start with a basic proxy configuration and layer on SSL, caching, rate limiting, and load balancing as your application demands grow. Nginx modular configuration makes incremental enhancement straightforward without downtime.