Node.js applications need a production-grade process manager to stay alive after crashes, utilize all CPU cores, and restart on server reboot. PM2 is the industry standard for Node.js process management, and paired with Nginx as a reverse proxy, it creates a robust, performant deployment stack.
This guide walks through every step: installing Node.js with version control via nvm, configuring PM2 for process management and cluster mode, setting up Nginx as a reverse proxy, securing with SSL, and implementing zero-downtime deployments. By the end, your Node.js application will be production-ready with automatic restarts, load balancing across CPU cores, and HTTPS termination.
Prerequisites
Before starting, you need:
- An Ubuntu 24.04 VPS. For a typical Node.js API or web app, deploy a Cloud VPS with 2 vCPU / 2 GB RAM — PM2 in cluster mode will use both cores.
- Root or sudo access. If you haven't secured your server yet, follow our Ubuntu VPS setup guide and security hardening guide first.
- A domain name with an A record pointing to your server's IP address.
- A Node.js application ready for deployment (Express, Fastify, Koa, Next.js, etc.).
Installing Node.js via nvm
Do not install Node.js from Ubuntu's default apt repositories. Those packages are often outdated and make it difficult to switch between versions. Instead, use nvm (Node Version Manager), which lets you install and switch between any Node.js version instantly.
Download and install nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
Reload your shell configuration:
source ~/.bashrc
Verify nvm is installed:
nvm --version
# 0.40.1
Install the latest LTS version of Node.js:
nvm install --lts
Verify the installation:
node --version
# v22.14.0
npm --version
# 10.9.2
Set this as the default version so it persists across new shell sessions:
nvm alias default node
If your application requires a specific Node.js version, install it directly:
nvm install 20.18.1
nvm use 20.18.1
Deploying Your Application
Create a directory for your application and deploy the code. The two most common methods are git clone and rsync from your local machine.
Option A: Git Clone
mkdir -p /var/www
cd /var/www
git clone git@github.com:youruser/yourapp.git myapp
cd myapp
npm install --production
Option B: Rsync from Local Machine
From your local development machine:
rsync -avz --exclude='node_modules' --exclude='.git' --exclude='.env' \
./myapp/ user@your-server-ip:/var/www/myapp/
Then on the server:
cd /var/www/myapp
npm install --production
Test that your application starts correctly:
node app.js
# or
node server.js
# or
node index.js
Confirm it responds on the expected port (typically 3000):
curl http://localhost:3000
Stop the process with Ctrl+C once you've verified it works. We'll hand process management over to PM2.
PM2 Setup: Start, Startup, Save
PM2 keeps your Node.js application running as a background daemon, restarts it if it crashes, and restarts it on server reboot. Install it globally:
npm install -g pm2
Start your application with PM2:
cd /var/www/myapp
pm2 start app.js --name myapp
Check the process status:
pm2 status
You should see output like:
┌─────┬────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├─────┼────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ myapp │ default │ 1.0.0 │ fork │ 12345 │ 5s │ 0 │ online │ 0% │ 45.2mb │ deploy │ disabled │
└─────┴────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
Now configure PM2 to start on server boot. The startup command generates a systemd unit:
pm2 startup systemd
PM2 will output a command you need to run with sudo. Copy and run it:
sudo env PATH=$PATH:/home/deploy/.nvm/versions/node/v22.14.0/bin \
/home/deploy/.nvm/versions/node/v22.14.0/lib/node_modules/pm2/bin/pm2 \
startup systemd -u deploy --hp /home/deploy
Save the current process list so PM2 knows what to restore after a reboot:
pm2 save
Test by rebooting the server:
sudo reboot
After reconnecting, verify your app is running:
pm2 status
PM2 Cluster Mode
By default, Node.js runs on a single CPU core. PM2's cluster mode forks your application across all available cores, with built-in load balancing. This is essential for CPU-bound operations and for maximizing throughput on multi-core servers.
Start your app in cluster mode with all available CPUs:
pm2 start app.js --name myapp -i max
Or specify the exact number of instances:
pm2 start app.js --name myapp -i 2
Check the status to see multiple instances:
pm2 status
┌─────┬────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │
├─────┼────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┤
│ 0 │ myapp │ default │ 1.0.0 │ cluster │ 1001 │ 10s │ 0 │ online │ 0.1% │ 52.3mb │
│ 1 │ myapp │ default │ 1.0.0 │ cluster │ 1002 │ 10s │ 0 │ online │ 0.1% │ 51.8mb │
└─────┴────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┘
Need consistent per-core performance under load? On shared VPS, CPU time is shared with other tenants, which means your PM2 cluster workers may not get equal CPU slices during peak hours. A Dedicated VPS (VDS) gives you physically dedicated CPU cores — every PM2 worker gets guaranteed, uncontested compute. This matters for CPU-intensive workloads like SSR rendering, image processing, or real-time WebSocket servers.
Important: Cluster mode requires your application to be stateless. Session data, WebSocket connections, and in-memory caches are not shared between workers. Use Redis for shared state (see the sticky sessions section of the PM2 documentation if you need session affinity).
PM2 Ecosystem File
Instead of passing all configuration options as command-line flags, define an ecosystem file. This is a JavaScript configuration that describes how PM2 should manage your application. Create ecosystem.config.js in your project root:
module.exports = {
apps: [
{
name: 'myapp',
script: './app.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000
},
// Restart if memory exceeds 512MB per worker
max_memory_restart: '512M',
// Exponential backoff restart delay
exp_backoff_restart_delay: 100,
// Merge logs from all cluster instances
merge_logs: true,
// Log configuration
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: '/var/log/pm2/myapp-error.log',
out_file: '/var/log/pm2/myapp-out.log',
// Watch for file changes (disable in production)
watch: false,
// Graceful shutdown timeout
kill_timeout: 5000,
// Wait for ready signal before considering app online
wait_ready: true,
listen_timeout: 10000
}
]
};
Create the log directory:
sudo mkdir -p /var/log/pm2
sudo chown deploy:deploy /var/log/pm2
Start the application using the ecosystem file:
pm2 start ecosystem.config.js
For the wait_ready feature to work, your application needs to send a ready signal after it finishes initializing:
const express = require('express');
const app = express();
// ... your routes and middleware ...
app.listen(process.env.PORT || 3000, () => {
console.log(`Server running on port ${process.env.PORT || 3000}`);
// Tell PM2 we're ready
if (process.send) {
process.send('ready');
}
});
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('Received SIGINT. Graceful shutdown...');
// Close database connections, finish pending requests, etc.
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
Nginx Reverse Proxy Configuration
Nginx sits in front of your Node.js application, handling SSL termination, static file serving, HTTP/2, request buffering, and load balancing. If you haven't installed Nginx yet, follow our complete Nginx reverse proxy guide. Here's the quick version:
sudo apt update
sudo apt install -y nginx
Create a server block for your domain:
sudo nano /etc/nginx/sites-available/myapp
Add this configuration:
upstream nodejs_backend {
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
# Redirect to HTTPS (after Certbot setup)
# return 301 https://$server_name$request_uri;
location / {
proxy_pass http://nodejs_backend;
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
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Serve static files directly (optional — adjust path)
location /static/ {
alias /var/www/myapp/public/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Block access to dotfiles
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
Enable the site and test the configuration:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
If you're using WebSockets (Socket.io, ws, etc.), the Upgrade and Connection headers in the configuration above already handle the WebSocket upgrade handshake.
Add response compression for text-based content. Edit /etc/nginx/nginx.conf and ensure gzip is enabled in the http block:
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
SSL with Certbot
Install Certbot and the Nginx plugin:
sudo apt install -y certbot python3-certbot-nginx
Obtain and install the certificate:
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Certbot will automatically modify your Nginx configuration to add SSL settings and redirect HTTP to HTTPS. Verify the auto-renewal timer is active:
sudo systemctl status certbot.timer
Test the renewal process:
sudo certbot renew --dry-run
After Certbot finishes, your Nginx configuration will include SSL blocks. You can further harden it by adding security headers inside the server block:
# 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 Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Environment Variables and .env Security
Never hardcode secrets in your application code. Use environment variables managed through a .env file. Install the dotenv package if your framework doesn't already include it:
npm install dotenv
Create a .env file in your project root:
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
SESSION_SECRET=your-random-64-character-string-here
API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
SMTP_HOST=smtp.example.com
SMTP_USER=noreply@yourdomain.com
SMTP_PASS=your-smtp-password
Load it at the very top of your entry file:
require('dotenv').config();
// Now process.env.DATABASE_URL is available
Secure the file so only the application user can read it:
chmod 600 /var/www/myapp/.env
chown deploy:deploy /var/www/myapp/.env
Critical: Add .env to your .gitignore file. Never commit secrets to version control.
echo ".env" >> .gitignore
Alternatively, you can define environment variables directly in the PM2 ecosystem file under the env key, but .env files are easier to manage on the server without modifying the ecosystem configuration.
MassiveGRID Ubuntu VPS Includes
Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10
→ Deploy a self-managed VPS — from $1.99/mo
→ Need dedicated resources? — from $19.80/mo
→ Want fully managed hosting? — we handle everything
Log Rotation with pm2-logrotate
PM2 logs grow indefinitely unless you configure rotation. Install the pm2-logrotate module:
pm2 install pm2-logrotate
Configure rotation settings:
# Maximum size of each log file before rotation
pm2 set pm2-logrotate:max_size 50M
# Keep 10 rotated log files
pm2 set pm2-logrotate:retain 10
# Enable compression of rotated logs
pm2 set pm2-logrotate:compress true
# Rotate on a schedule (every day at midnight)
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'
# Use date format in rotated file names
pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
Verify the configuration:
pm2 conf pm2-logrotate
You can also view logs in real time with:
# All logs
pm2 logs
# Specific app logs
pm2 logs myapp
# Last 200 lines
pm2 logs myapp --lines 200
For comprehensive monitoring beyond logs, see our Ubuntu VPS monitoring guide which covers system-level metrics, alerting, and dashboards.
Zero-Downtime Deployments
PM2's reload command performs a graceful restart — it starts new worker processes, waits for them to be ready, then shuts down the old ones. No requests are dropped during the process.
Here's a basic deployment script you can save as deploy.sh:
#!/bin/bash
set -e
APP_DIR="/var/www/myapp"
BRANCH="main"
echo "==> Pulling latest code..."
cd $APP_DIR
git fetch origin
git reset --hard origin/$BRANCH
echo "==> Installing dependencies..."
npm install --production
echo "==> Reloading PM2 (zero-downtime)..."
pm2 reload ecosystem.config.js
echo "==> Saving PM2 process list..."
pm2 save
echo "==> Deployment complete!"
pm2 status
Make it executable:
chmod +x /var/www/myapp/deploy.sh
The key difference between pm2 reload and pm2 restart:
pm2 restart— kills all processes, then starts new ones. There is a brief period of downtime.pm2 reload— starts new processes first, routes traffic to them, then kills old processes. Zero downtime, but requires cluster mode.
For reload to work properly, your application needs to handle the SIGINT signal for graceful shutdown. This allows the old process to finish handling current requests before exiting:
const server = app.listen(PORT, () => {
console.log(`Worker ${process.pid} listening on ${PORT}`);
if (process.send) process.send('ready');
});
process.on('SIGINT', () => {
console.log(`Worker ${process.pid} shutting down...`);
server.close(() => {
console.log(`Worker ${process.pid} closed`);
process.exit(0);
});
// Force exit after 5 seconds if connections won't close
setTimeout(() => {
console.error('Forcing exit after timeout');
process.exit(1);
}, 5000);
});
Advanced: Monitoring and Diagnostics
PM2 includes a built-in monitoring dashboard:
pm2 monit
This shows real-time CPU usage, memory consumption, loop delay, and logs for each process. For quick diagnostics:
# Detailed info about an app
pm2 describe myapp
# Show a live dashboard
pm2 monit
# Reset restart count
pm2 reset myapp
# Clear all logs
pm2 flush
If you notice memory increasing over time (a memory leak), PM2's max_memory_restart setting in the ecosystem file will automatically restart workers that exceed the threshold. Track this metric over time with the monitoring approach described in our monitoring setup guide.
Troubleshooting Common Issues
App Shows "Errored" in PM2 Status
Check the error logs:
pm2 logs myapp --err --lines 100
Common causes: missing environment variables, port already in use, missing dependencies.
App Keeps Restarting (Restart Loop)
If the restart count keeps climbing, check for startup errors:
pm2 logs myapp --lines 50
The exp_backoff_restart_delay setting in the ecosystem file prevents PM2 from overwhelming your system with rapid restarts. Each restart waits longer than the previous one.
Port Conflicts
If you're running multiple Node.js apps, each needs a unique port. Check what's using a port:
sudo lsof -i :3000
Permission Errors
Ensure the application user owns all relevant files:
sudo chown -R deploy:deploy /var/www/myapp
sudo chown -R deploy:deploy /var/log/pm2
nvm Not Found After Reboot
If PM2 can't find Node.js after a reboot, it's because the startup script doesn't source nvm. Regenerate the startup script:
pm2 unstartup systemd
pm2 startup systemd
Run the generated command, then save again:
pm2 save
Complete Configuration Reference
Here's a summary of all the files involved in this deployment:
# Directory structure after deployment
/var/www/myapp/
├── app.js # Application entry point
├── ecosystem.config.js # PM2 configuration
├── .env # Environment variables (chmod 600)
├── deploy.sh # Deployment script
├── package.json
├── package-lock.json
├── node_modules/
├── public/ # Static assets (served by Nginx)
│ └── static/
└── src/ # Application source
/etc/nginx/sites-available/
└── myapp # Nginx server block
/var/log/pm2/
├── myapp-out.log # Application stdout
└── myapp-error.log # Application stderr
Prefer Managed Deployment?
If you don't want to manage process uptime, log rotation, security patches, SSL renewals, and server maintenance yourself, consider MassiveGRID's Managed Dedicated Cloud Servers. The managed service handles infrastructure administration — operating system updates, security hardening, monitoring, backups, and 24/7 incident response — so you can focus entirely on building your Node.js application. Every managed server runs on a Proxmox HA cluster with automatic failover and Ceph triple-replicated NVMe storage, giving you enterprise reliability without the operational burden.
Next Steps
- Ubuntu VPS initial setup guide — if you haven't configured your server yet
- Security hardening guide — firewall, SSH hardening, fail2ban
- Complete Nginx reverse proxy guide — advanced Nginx configuration, rate limiting, caching
- VPS monitoring setup — system metrics, alerting, and dashboards for production