Set up PM2 clustering to scale Node.js applications across CPU cores with NGINX load balancing. Monitor performance and optimize resource utilization for high-traffic production workloads.
Prerequisites
- Root or sudo access
- Basic Node.js knowledge
- Understanding of reverse proxies
What this solves
Single-threaded Node.js applications can't utilize multiple CPU cores effectively, creating performance bottlenecks under high load. PM2 clustering spawns multiple application instances across all available cores while NGINX distributes incoming requests efficiently. This configuration maximizes server resource utilization and provides automatic process management with zero-downtime deployments.
Step-by-step configuration
Update system packages
Start by updating your package manager to ensure you get the latest versions of all dependencies.
sudo apt update && sudo apt upgrade -y
Install Node.js and npm
Install the latest LTS version of Node.js using NodeSource repository for consistent versions across distributions.
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs
Verify the installation:
node --version
npm --version
Install PM2 globally
PM2 is a production process manager that handles clustering, monitoring, and zero-downtime deployments for Node.js applications.
sudo npm install -g pm2
Verify PM2 installation:
pm2 --version
Create a sample Node.js application
Create a simple Express application to demonstrate clustering. This app will show which worker process handles each request.
mkdir -p /var/www/myapp
cd /var/www/myapp
{
"name": "clustered-app",
"version": "1.0.0",
"description": "Node.js clustering demo",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
const express = require('express');
const app = express();
const port = 3000;
// Middleware to log requests
app.use((req, res, next) => {
console.log(Worker ${process.pid} handling ${req.method} ${req.path});
next();
});
// Basic route
app.get('/', (req, res) => {
res.json({
message: 'Hello from Node.js cluster!',
worker_pid: process.pid,
timestamp: new Date().toISOString()
});
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
worker_pid: process.pid,
uptime: process.uptime()
});
});
// CPU intensive route for testing
app.get('/cpu', (req, res) => {
const start = Date.now();
// Simulate CPU work
while (Date.now() - start < 1000) {
Math.random();
}
res.json({
message: 'CPU intensive task completed',
worker_pid: process.pid,
duration: Date.now() - start
});
});
app.listen(port, '127.0.0.1', () => {
console.log(App running on port ${port}, PID: ${process.pid});
});
Install application dependencies
Install the Express framework and other dependencies for the demo application.
cd /var/www/myapp
npm install
Create PM2 ecosystem configuration
The ecosystem file defines how PM2 should manage your application, including clustering settings and environment variables.
module.exports = {
apps: [{
name: 'myapp',
script: 'app.js',
instances: 'max', // Use all available CPU cores
exec_mode: 'cluster', // Enable clustering mode
env: {
NODE_ENV: 'production',
PORT: 3000
},
// PM2 clustering options
instance_var: 'INSTANCE_ID',
// Monitoring and logging
log_file: '/var/log/pm2/myapp.log',
error_file: '/var/log/pm2/myapp-error.log',
out_file: '/var/log/pm2/myapp-out.log',
// Auto-restart settings
max_memory_restart: '500M',
min_uptime: '10s',
max_restarts: 10,
// Advanced options
watch: false, // Don't watch files in production
ignore_watch: ['node_modules', 'logs'],
// Process management
kill_timeout: 5000,
listen_timeout: 3000,
// Resource limits
node_args: '--max-old-space-size=512'
}]
};
Create PM2 log directory
Create the log directory and set proper permissions for PM2 to write log files.
sudo mkdir -p /var/log/pm2
sudo chown -R $USER:$USER /var/log/pm2
Start application with PM2 clustering
Launch your application using the ecosystem configuration. PM2 will automatically spawn one process per CPU core.
cd /var/www/myapp
pm2 start ecosystem.config.js
Check the status of your clustered application:
pm2 list
pm2 show myapp
Install and configure NGINX
Install NGINX to act as a load balancer and reverse proxy for your PM2 cluster.
sudo apt install -y nginx
Configure NGINX load balancing
Create an NGINX configuration that load balances requests across your PM2 cluster instances. This configuration includes health checks and connection optimization.
upstream nodejs_backend {
# PM2 cluster instances running on port 3000
server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
# Load balancing method
least_conn; # Use least connections algorithm
# Keep alive connections to backend
keepalive 32;
}
server {
listen 80;
server_name example.com www.example.com;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
# Logging
access_log /var/log/nginx/myapp_access.log;
error_log /var/log/nginx/myapp_error.log;
# Main application proxy
location / {
proxy_pass http://nodejs_backend;
# Proxy headers
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 timeouts
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffer settings
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# Connection settings
proxy_cache_bypass $http_upgrade;
}
# Health check endpoint
location /health {
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
access_log off;
}
# Static files (if you have any)
location /static/ {
alias /var/www/myapp/public/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}
Enable NGINX site configuration
Enable the new site configuration and test the NGINX configuration for syntax errors.
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
Remove the default NGINX site if present:
sudo rm -f /etc/nginx/sites-enabled/default
Start and enable services
Start NGINX and configure PM2 to automatically start on system boot.
sudo systemctl enable --now nginx
sudo systemctl status nginx
Configure PM2 startup script:
pm2 startup
pm2 save
Configure firewall rules
Allow HTTP traffic through the firewall while blocking direct access to the Node.js port.
sudo ufw allow 'Nginx Full'
sudo ufw enable
Monitor and optimize performance
Monitor PM2 cluster performance
Use PM2's built-in monitoring tools to track cluster performance and resource usage.
# Real-time monitoring
pm2 monit
Detailed process information
pm2 show myapp
View logs from all cluster instances
pm2 logs myapp
Memory and CPU usage
pm2 ls
Test load balancing
Verify that requests are being distributed across different worker processes.
# Test multiple requests to see different PIDs
for i in {1..10}; do
curl -s http://localhost/ | grep worker_pid
done
Configure PM2 monitoring dashboard
Set up PM2's web monitoring interface for visual performance tracking. This step links to our system monitoring tutorial for comprehensive server monitoring.
# Enable PM2 web monitoring (optional)
pm2 web
View monitoring on http://localhost:9615
Optimize PM2 cluster configuration
Fine-tune your cluster configuration based on your application's memory and CPU usage patterns.
# Update the ecosystem.config.js with optimized settings
module.exports = {
apps: [{
name: 'myapp',
script: 'app.js',
instances: 4, // Specific number instead of 'max' for predictable resource usage
exec_mode: 'cluster',
// Memory optimization
max_memory_restart: '300M', // Restart if memory exceeds 300MB
node_args: '--max-old-space-size=256', // Limit V8 heap size
// Performance settings
instance_var: 'INSTANCE_ID',
increment_var: 'PORT',
// Graceful shutdowns
kill_timeout: 3000,
listen_timeout: 3000,
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
};
Restart the application with new configuration:
pm2 reload myapp
pm2 save
Verify your setup
# Check PM2 cluster status
pm2 list
Test application response
curl -s http://localhost/ | jq .
Check NGINX status
sudo systemctl status nginx
Test load distribution
for i in {1..5}; do curl -s http://localhost/health | grep worker_pid; done
Monitor cluster performance
pm2 monit
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| PM2 processes keep restarting | Memory limit exceeded | Increase max_memory_restart or optimize app memory usage |
| 502 Bad Gateway from NGINX | Node.js processes not running | Check pm2 list and restart with pm2 reload myapp |
| All requests go to same worker | Session affinity or connection pooling | Ensure stateless application design and proper upstream configuration |
| High CPU usage on startup | Too many instances for available cores | Set specific instance count instead of 'max' in ecosystem config |
| Log files growing too large | No log rotation configured | Configure PM2 log rotation with pm2 install pm2-logrotate |
| NGINX connection errors | Proxy timeout too low | Increase proxy_read_timeout and proxy_send_timeout values |
Next steps
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default values
APP_NAME="myapp"
APP_DIR="/var/www/myapp"
APP_PORT="3000"
NGINX_PORT="80"
# Usage message
usage() {
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " -n, --name NAME Application name (default: myapp)"
echo " -d, --dir DIR Application directory (default: /var/www/myapp)"
echo " -p, --port PORT Application port (default: 3000)"
echo " -w, --web-port PORT NGINX port (default: 80)"
echo " -h, --help Show this help message"
exit 1
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-n|--name)
APP_NAME="$2"
shift 2
;;
-d|--dir)
APP_DIR="$2"
shift 2
;;
-p|--port)
APP_PORT="$2"
shift 2
;;
-w|--web-port)
NGINX_PORT="$2"
shift 2
;;
-h|--help)
usage
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
usage
;;
esac
done
# Cleanup function for rollback
cleanup() {
echo -e "${YELLOW}[ERROR] Script failed. Cleaning up...${NC}"
pm2 delete "$APP_NAME" 2>/dev/null || true
systemctl stop nginx 2>/dev/null || true
rm -f "/etc/nginx/sites-available/$APP_NAME" "/etc/nginx/conf.d/$APP_NAME.conf" 2>/dev/null || true
rm -rf "$APP_DIR" 2>/dev/null || true
}
trap cleanup ERR
# Check prerequisites
check_prerequisites() {
if [[ $EUID -eq 0 ]]; then
echo -e "${RED}This script should not be run as root${NC}"
exit 1
fi
if ! sudo -n true 2>/dev/null; then
echo -e "${RED}This script requires sudo privileges${NC}"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo -e "${RED}curl is required but not installed${NC}"
exit 1
fi
}
# Detect distribution
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"
PKG_UPDATE="apt update && apt upgrade -y"
PKG_INSTALL="apt install -y"
NGINX_CONFIG_DIR="/etc/nginx/sites-available"
NGINX_ENABLED_DIR="/etc/nginx/sites-enabled"
;;
almalinux|rocky|centos|rhel|ol)
PKG_MGR="dnf"
PKG_UPDATE="dnf update -y"
PKG_INSTALL="dnf install -y"
NGINX_CONFIG_DIR="/etc/nginx/conf.d"
NGINX_ENABLED_DIR=""
;;
fedora)
PKG_MGR="dnf"
PKG_UPDATE="dnf update -y"
PKG_INSTALL="dnf install -y"
NGINX_CONFIG_DIR="/etc/nginx/conf.d"
NGINX_ENABLED_DIR=""
;;
amzn)
PKG_MGR="yum"
PKG_UPDATE="yum update -y"
PKG_INSTALL="yum install -y"
NGINX_CONFIG_DIR="/etc/nginx/conf.d"
NGINX_ENABLED_DIR=""
;;
*)
echo -e "${RED}Unsupported distribution: $ID${NC}"
exit 1
;;
esac
else
echo -e "${RED}Cannot detect distribution${NC}"
exit 1
fi
}
echo -e "${BLUE}Starting Node.js PM2 clustering setup...${NC}"
check_prerequisites
detect_distro
echo -e "${GREEN}[1/10] Updating system packages...${NC}"
sudo $PKG_UPDATE
echo -e "${GREEN}[2/10] Installing Node.js and npm...${NC}"
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - >/dev/null 2>&1
sudo $PKG_INSTALL nodejs
echo -e "${GREEN}[3/10] Installing PM2 globally...${NC}"
sudo npm install -g pm2
echo -e "${GREEN}[4/10] Creating application directory...${NC}"
sudo mkdir -p "$APP_DIR"
sudo chown -R "$USER:$USER" "$APP_DIR"
echo -e "${GREEN}[5/10] Creating Node.js application...${NC}"
cat > "$APP_DIR/package.json" << EOF
{
"name": "$APP_NAME",
"version": "1.0.0",
"description": "Node.js clustering demo",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
EOF
cat > "$APP_DIR/app.js" << EOF
const express = require('express');
const app = express();
const port = $APP_PORT;
app.use((req, res, next) => {
console.log(\`Worker \${process.pid} handling \${req.method} \${req.path}\`);
next();
});
app.get('/', (req, res) => {
res.json({
message: 'Hello from Node.js cluster!',
worker_pid: process.pid,
timestamp: new Date().toISOString()
});
});
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
worker_pid: process.pid,
uptime: process.uptime()
});
});
app.get('/cpu', (req, res) => {
const start = Date.now();
while (Date.now() - start < 1000) {
Math.random();
}
res.json({
message: 'CPU intensive task completed',
worker_pid: process.pid,
duration: Date.now() - start
});
});
app.listen(port, '127.0.0.1', () => {
console.log(\`App running on port \${port}, PID: \${process.pid}\`);
});
EOF
echo -e "${GREEN}[6/10] Installing application dependencies...${NC}"
cd "$APP_DIR"
npm install
echo -e "${GREEN}[7/10] Creating PM2 ecosystem configuration...${NC}"
sudo mkdir -p /var/log/pm2
sudo chown -R "$USER:$USER" /var/log/pm2
cat > "$APP_DIR/ecosystem.config.js" << EOF
module.exports = {
apps: [{
name: '$APP_NAME',
script: 'app.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: $APP_PORT
},
instance_var: 'INSTANCE_ID',
log_file: '/var/log/pm2/$APP_NAME.log',
error_file: '/var/log/pm2/$APP_NAME-error.log',
out_file: '/var/log/pm2/$APP_NAME-out.log',
max_memory_restart: '500M',
min_uptime: '10s',
max_restarts: 10,
watch: false,
ignore_watch: ['node_modules', 'logs'],
kill_timeout: 5000,
listen_timeout: 3000,
node_args: '--max-old-space-size=512'
}]
};
EOF
echo -e "${GREEN}[8/10] Starting application with PM2...${NC}"
pm2 start "$APP_DIR/ecosystem.config.js"
pm2 startup | grep -v "PM2" | bash || true
pm2 save
echo -e "${GREEN}[9/10] Installing and configuring NGINX...${NC}"
sudo $PKG_INSTALL nginx
if [ "$PKG_MGR" = "apt" ]; then
NGINX_CONFIG="$NGINX_CONFIG_DIR/$APP_NAME"
cat > "/tmp/$APP_NAME.nginx" << EOF
upstream $APP_NAME {
least_conn;
server 127.0.0.1:$APP_PORT;
}
server {
listen $NGINX_PORT;
server_name _;
location / {
proxy_pass http://$APP_NAME;
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;
}
}
EOF
sudo mv "/tmp/$APP_NAME.nginx" "$NGINX_CONFIG"
sudo ln -sf "$NGINX_CONFIG" "$NGINX_ENABLED_DIR/$APP_NAME"
sudo rm -f /etc/nginx/sites-enabled/default
else
NGINX_CONFIG="$NGINX_CONFIG_DIR/$APP_NAME.conf"
cat > "/tmp/$APP_NAME.conf" << EOF
upstream $APP_NAME {
least_conn;
server 127.0.0.1:$APP_PORT;
}
server {
listen $NGINX_PORT;
server_name _;
location / {
proxy_pass http://$APP_NAME;
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;
}
}
EOF
sudo mv "/tmp/$APP_NAME.conf" "$NGINX_CONFIG"
fi
sudo nginx -t
sudo systemctl enable nginx
sudo systemctl restart nginx
echo -e "${GREEN}[10/10] Verifying installation...${NC}"
sleep 3
if ! pm2 list | grep -q "$APP_NAME.*online"; then
echo -e "${RED}PM2 application is not running${NC}"
exit 1
fi
if ! sudo systemctl is-active --quiet nginx; then
echo -e "${RED}NGINX is not running${NC}"
exit 1
fi
if ! curl -s "http://localhost:$NGINX_PORT/health" >/dev/null; then
echo -e "${RED}Application is not responding through NGINX${NC}"
exit 1
fi
echo -e "${GREEN}Installation completed successfully!${NC}"
echo -e "${BLUE}Application: $APP_NAME${NC}"
echo -e "${BLUE}Directory: $APP_DIR${NC}"
echo -e "${BLUE}App Port: $APP_PORT${NC}"
echo -e "${BLUE}Web Port: $NGINX_PORT${NC}"
echo -e "${BLUE}PM2 Status: pm2 status${NC}"
echo -e "${BLUE}Test URL: curl http://localhost:$NGINX_PORT${NC}"
Review the script before running. Execute with: bash install.sh