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.
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
}]
};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
});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
-
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. -
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. -
Set
trust proxyin Express: Without this, Express sees the Nginx IP as the client IP, breaking rate limiting, logging, and security features that depend on client IP. -
Use
least_connload balancing: Node.js request processing times vary significantly based on the operation. Least connections ensures even distribution. -
Serve static files from Nginx: Let Nginx serve CSS, JS, images, and other static assets. Use
expiresandCache-Controlheaders for aggressive caching. -
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.
-
Implement health check endpoints: Add a
/healthendpoint to your Node.js application that Nginx can check to determine backend availability. -
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
| Pitfall | Impact | Solution |
|---|---|---|
| Not setting trust proxy in Express | Wrong client IP in logs and rate limiting | Add app.set('trust proxy', 1) |
| Missing WebSocket upgrade headers | WebSocket connections fail silently | Add Upgrade and Connection headers for WS locations |
| No keepalive to upstream | New TCP connection per request, high latency | Use keepalive in upstream block |
| Serving static files through Node.js | Poor performance under load | Serve static files directly from Nginx |
| No health check endpoint | Nginx sends traffic to crashed backends | Implement /health endpoint and use max_fails |
| Incorrect proxy_buffer_size | Large responses truncated or slow | Increase 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/dataFuture 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-descriptionBuilding 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.
Staying Current with Industry Trends
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:
- Always run Node.js behind Nginx in production
- Use PM2 with cluster mode for multi-instance Node.js
- Configure trust proxy in Express to get correct client IPs
- Serve static files from Nginx, not Node.js
- Set up WebSocket proxying with proper upgrade headers
- Enable gzip compression and response caching at the Nginx layer
- Implement rate limiting to protect your backend
- 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.