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:

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:

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