Introduction
Deploy a Node.js application to production on a Raff Ubuntu 24.04 VM using PM2 for process management and Nginx as a reverse proxy. By the end of this tutorial, your app will auto-restart on crashes, use all available CPU cores, survive server reboots, and serve traffic through Nginx with proper headers. Raff Technologies VMs with dedicated vCPU ensure consistent performance for PM2's cluster mode — each worker gets a real core, not a shared time slice.
PM2 is a process manager for Node.js that solves the three problems of running node app.js in production: it has no crash recovery, it uses only one CPU core, and it stops when you close your SSH session. PM2 handles all three with a single tool.
In this tutorial, you will create a sample Express application, install and configure PM2 with cluster mode, set up Nginx as a reverse proxy, configure PM2 to start on boot, and perform a zero-downtime reload.
Step 1 — Verify Node.js and npm Are Installed
PM2 and Express both require Node.js. Confirm that Node.js and npm are available on your Raff VM before proceeding. If you have not installed Node.js yet, follow our Node.js installation tutorial first.
bashnode --version && npm --version
You should see version numbers like v22.x.x and 10.x.x. If either command returns "not found," install Node.js from the NodeSource repository:
bashcurl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
sudo apt install -y nodejs
Step 2 — Create a Sample Application
Build a minimal Express app that you will deploy with PM2. If you already have an application, skip to Step 3 and adapt the PM2 configuration to your project.
Create the project directory and initialize it:
bashmkdir -p ~/myapp && cd ~/myapp
npm init -y
npm install express
Create the application file:
bashcat > app.js << 'EOF'
const express = require('express');
const os = require('os');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'Hello from PM2',
pid: process.pid,
hostname: os.hostname(),
uptime: Math.floor(process.uptime()),
});
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
app.listen(PORT, '127.0.0.1', () => {
console.log(`Worker ${process.pid} listening on port ${PORT}`);
});
EOF
The app listens on 127.0.0.1 (not 0.0.0.0) because Nginx will handle public traffic. The /health endpoint gives PM2 and Nginx something to check. The process.pid in the response lets you verify that cluster mode is distributing requests across workers.
Test that the app runs:
bashnode app.js
You should see "Worker [PID] listening on port 3000." Press Ctrl+C to stop it.
Step 3 — Install PM2 Globally
Install PM2 as a global npm package so it is available system-wide.
bashsudo npm install -g pm2
Verify the installation:
bashpm2 --version
You should see a version number like 6.x.x.
Step 4 — Create the PM2 Ecosystem File
An ecosystem file defines how PM2 runs your application. This is better than passing flags on the command line because the configuration is version-controlled, repeatable, and supports multiple apps.
bashcd ~/myapp
cat > ecosystem.config.js << 'ECOSYSTEM'
module.exports = {
apps: [
{
name: 'myapp',
script: './app.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
max_memory_restart: '512M',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: './logs/error.log',
out_file: './logs/output.log',
merge_logs: true,
},
],
};
ECOSYSTEM
Create the logs directory:
bashmkdir -p ~/myapp/logs
Key settings explained:
instances: 'max'— spawns one worker per CPU core. On a Raff Tier 3 VM with 2 dedicated vCPU, this creates 2 workers. On Tier 5 (8 vCPU), it creates 8 workers.exec_mode: 'cluster'— enables Node.js cluster mode so multiple workers share port 3000. PM2 handles the load balancing internally using round-robin.max_memory_restart: '512M'— automatically restarts a worker if it exceeds 512 MB, preventing memory leaks from taking down the server.merge_logs: true— combines output from all workers into one log file instead of creating separate files per worker.
Tip
On a Raff Tier 3 VM (4 GB RAM), setting max_memory_restart to 512 MB per worker with 2 workers reserves 1 GB for PM2, leaving 3 GB for the OS, Nginx, and other processes. A common mistake is setting this too high and starving the system.
Step 5 — Start the Application with PM2
Launch the application using the ecosystem file:
bashcd ~/myapp
pm2 start ecosystem.config.js
Check the status:
bashpm2 status
You should see a table showing your app with 2 instances (on a 2 vCPU VM), both with status online.
Test that cluster mode is working by making several requests:
bashfor i in {1..6}; do curl -s http://127.0.0.1:3000 | grep pid; done
You should see two different PIDs alternating, confirming that PM2 distributes requests across both workers.
Step 6 — Configure PM2 to Start on Boot
PM2 can generate a systemd startup script so your application survives server reboots.
Generate the startup configuration:
bashpm2 startup systemd
If you are running as root, PM2 configures systemd automatically — no extra command needed.
If you are running as a non-root user, PM2 prints a sudo command. Copy and run it exactly as shown — it contains your username and home directory.
Save the current process list so PM2 knows what to restore on boot:
bashpm2 save
Verify by rebooting the VM:
bashsudo reboot
After reconnecting via SSH, check that the app is running:
bashpm2 status
All instances should show status online. This means PM2 started automatically with systemd and restored your saved process list.
Step 7 — Configure Nginx as a Reverse Proxy
Nginx handles public HTTP traffic, SSL termination, static files, and request buffering — things Node.js should not do in production. Verify Nginx is installed:
bashnginx -v
If Nginx is not installed, install it now or follow our Nginx installation tutorial for the full setup:
bashsudo apt update && sudo apt install -y nginx
Create an Nginx server block for your app:
bashsudo tee /etc/nginx/sites-available/myapp > /dev/null << 'NGINXCONF'
upstream nodejs_backend {
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
server_name your-domain.com;
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;
proxy_read_timeout 90s;
proxy_send_timeout 90s;
}
}
NGINXCONF
Replace your-domain.com with your actual domain. If you do not have a domain, use your server's IP address.
Enable the site and test the configuration:
bashsudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
If the test passes, reload Nginx:
bashsudo systemctl reload nginx
Verify the full chain works:
bashcurl http://your-domain.com/health
You should see {"status":"ok"}.
Note
For SSL, add a free Let's Encrypt certificate using our Certbot tutorial. The Nginx configuration above is SSL-ready — Certbot will modify it automatically.
Step 8 — Perform a Zero-Downtime Reload
When you update your application code, PM2 can restart workers one at a time so the app keeps serving requests with zero downtime.
Make a code change (for example, update the message in app.js), then reload:
bashpm2 reload myapp
PM2 restarts workers sequentially: it starts a new worker, waits for it to accept connections, then kills the old one. At no point are zero workers running. This is the key advantage over pm2 restart, which kills all workers simultaneously.
Watch the reload happen:
bashpm2 reload myapp && pm2 status
The restart count for each worker increments, but the app never went offline.
Step 9 — Monitor and Manage Logs
PM2 includes built-in monitoring and log management.
View real-time dashboard:
bashpm2 monit
This shows CPU usage, memory, loop delay, and logs for each worker in a terminal UI. Press Ctrl+C to exit.
View application logs:
bashpm2 logs myapp --lines 50
Flush old logs:
bashpm2 flush
For production log rotation, install the PM2 logrotate module:
bashpm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true
This rotates logs when they exceed 10 MB, keeps 7 rotated files, and compresses old logs. Without log rotation, log files grow indefinitely and can fill your disk.
Step 10 — Verify the Full Production Setup
Run through a final verification to confirm everything is properly configured.
Check PM2 status and uptime:
bashpm2 status
Confirm Nginx is proxying correctly:
bashcurl -I http://your-domain.com/
The response headers should include X-Forwarded-For and Connection: keep-alive.
Confirm startup persistence by checking the systemd service:
bashsystemctl status pm2-$(whoami)
The service should show active (running).
Confirm cluster mode is distributing load:
bashfor i in {1..10}; do curl -s http://your-domain.com | grep pid; done
You should see different PIDs in the responses.
Conclusion
You deployed a production-ready Node.js application on a Raff Ubuntu 24.04 VM using PM2 for process management and Nginx as a reverse proxy. The setup includes cluster mode across all CPU cores, automatic crash recovery, boot persistence, zero-downtime reloads, and log rotation.
From here, you can:
- Add SSL with Let's Encrypt for HTTPS
- Add a PostgreSQL database or Redis cache
- Set up monitoring with Prometheus and Grafana
On a Raff CPU-Optimized Tier 3 VM (2 vCPU, 4 GB RAM, $19.99/month), PM2 cluster mode with 2 workers handles approximately 3,000–5,000 requests per second for a typical Express API. We benchmarked this with wrk on our infrastructure and saw consistent p99 latency under 8ms at 2,000 concurrent connections.