Process Management with PM2
A practical guide to managing Node.js applications with PM2, covering cluster mode, ecosystem configuration, log management, zero-downtime deployments, and production monitoring.
Process Management with PM2
Node.js processes crash. They leak memory. They run on a single core while your 8-core server idles at 12% CPU utilization. PM2 is the process manager that solves all three of these problems out of the box, giving you automatic restarts, cluster mode, log management, and zero-downtime deployments with a single tool. If you are running a Node.js application in production without a process manager, you are one uncaught exception away from downtime.
Prerequisites
- Node.js v16+ installed (v18 or v20 LTS recommended for production)
- Basic familiarity with Express.js
- A Linux server or macOS for production deployment examples (PM2 runs on Windows but startup scripts and deploy features are Linux/macOS only)
- npm packages:
express
npm install -g pm2
npm install express
Verify the installation:
pm2 --version
5.3.1
Why Process Managers Matter for Node.js
Node.js runs your application as a single OS process. When that process crashes — and it will crash — your application goes down. There is no built-in mechanism in Node.js to restart itself after a fatal error. A bare node app.js in production is a liability.
Here is what happens without a process manager:
node app.js &
# Application starts on PID 28451
# 3 hours later, an unhandled promise rejection kills it
# Nobody notices for 45 minutes
# Revenue lost. Customers angry.
A process manager watches your application process and restarts it automatically when it dies. But PM2 does far more than that. It provides:
- Automatic restarts on crash, with configurable restart limits and delays
- Cluster mode to fork across all CPU cores without changing your code
- Log management with rotation, timestamps, and centralized collection
- Monitoring with CPU/memory metrics in real time
- Zero-downtime deployments with graceful reload
- Startup scripts so your apps survive server reboots
- Environment management for staging vs. production configurations
- Remote deployment with built-in SSH deploy workflows
You could stitch together systemd, cluster module code, logrotate, and custom deployment scripts to achieve the same thing. Or you could install PM2 and have it all in five minutes.
PM2 Installation and Basic Usage
PM2 installs globally via npm:
npm install -g pm2
Start an application:
pm2 start app.js
[PM2] Starting /home/deploy/myapp/app.js in fork_mode (1 instance)
[PM2] Done.
┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ app │ default │ 1.0.0 │ fork │ 28451 │ 0s │ 0 │ online │ 0% │ 42.1mb │ deploy │ disabled │
└────┘──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
The essential commands you will use every day:
# List all processes
pm2 list
# Stop a process
pm2 stop app
# Restart a process (hard restart — drops connections)
pm2 restart app
# Reload a process (graceful — zero downtime)
pm2 reload app
# Delete a process from PM2's list
pm2 delete app
# View logs
pm2 logs
# View detailed info about a process
pm2 describe app
# Monitor CPU/memory in real time
pm2 monit
You can reference processes by name, by id, or use all to target every managed process:
pm2 restart all
pm2 stop 0
pm2 logs my-api
Ecosystem File Configuration
Running pm2 start app.js --name my-api -i 4 works, but it is not repeatable and it is not version-controllable. The ecosystem file is where PM2 becomes a real operations tool.
Generate a starter file:
pm2 ecosystem
This creates ecosystem.config.js. Here is a real-world configuration:
module.exports = {
apps: [
{
name: "my-api",
script: "./app.js",
instances: "max",
exec_mode: "cluster",
watch: false,
max_memory_restart: "500M",
env: {
NODE_ENV: "development",
PORT: 3000
},
env_production: {
NODE_ENV: "production",
PORT: 8080
},
env_staging: {
NODE_ENV: "staging",
PORT: 8080
},
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "./logs/err.log",
out_file: "./logs/out.log",
merge_logs: true,
restart_delay: 4000,
max_restarts: 10,
min_uptime: "10s",
kill_timeout: 5000,
listen_timeout: 8000,
shutdown_with_message: true
}
]
};
Start with a specific environment:
pm2 start ecosystem.config.js --env production
Every option in the ecosystem file has a purpose. Let me walk through the important ones.
instances: "max" forks one worker per CPU core. You can also set a specific number like 4 or use -1 to mean "max minus one" (leaving a core for the OS).
exec_mode: "cluster" enables PM2's built-in cluster mode. The alternative is "fork", which runs a single instance. You must set this to "cluster" for the instances setting to do anything.
max_memory_restart: "500M" automatically restarts a worker when it exceeds 500 MB of memory. This is your safety net against memory leaks. I set this on every production application.
min_uptime: "10s" tells PM2 that if a process crashes within 10 seconds of starting, it counts as an "unstable restart." After max_restarts unstable restarts, PM2 stops trying. This prevents a crash loop from hammering your server.
kill_timeout: 5000 gives your process 5 seconds to shut down gracefully after receiving SIGINT before PM2 sends SIGKILL. Increase this if your application needs time to finish database transactions or drain HTTP connections.
listen_timeout: 8000 is how long PM2 waits for your app to signal it is ready during a reload. If your application takes a while to connect to databases on startup, increase this value.
Cluster Mode for Multi-Core Utilization
Cluster mode is PM2's killer feature. Without changing a single line of your application code, PM2 forks your process across all available CPU cores.
Here is a basic Express application:
var express = require("express");
var os = require("os");
var app = express();
var PORT = process.env.PORT || 3000;
app.get("/", function (req, res) {
res.json({
pid: process.pid,
uptime: process.uptime(),
memory: Math.round(process.memoryUsage().rss / 1024 / 1024) + " MB",
hostname: os.hostname()
});
});
app.get("/health", function (req, res) {
res.status(200).json({ status: "healthy", pid: process.pid });
});
app.listen(PORT, function () {
console.log("Worker " + process.pid + " listening on port " + PORT);
});
Start it in cluster mode:
pm2 start app.js -i max --name my-api
[PM2] Starting /home/deploy/myapp/app.js in cluster_mode (8 instances)
[PM2] Done.
┌────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0 │ my-api │ default │ 1.0.0 │ cluster │ 14201 │ 0s │ 0 │ online │ 0% │ 43.2mb │ deploy │ disabled │
│ 1 │ my-api │ default │ 1.0.0 │ cluster │ 14208 │ 0s │ 0 │ online │ 0% │ 42.8mb │ deploy │ disabled │
│ 2 │ my-api │ default │ 1.0.0 │ cluster │ 14215 │ 0s │ 0 │ online │ 0% │ 43.5mb │ deploy │ disabled │
│ 3 │ my-api │ default │ 1.0.0 │ cluster │ 14222 │ 0s │ 0 │ online │ 0% │ 42.1mb │ deploy │ disabled │
│ 4 │ my-api │ default │ 1.0.0 │ cluster │ 14229 │ 0s │ 0 │ online │ 0% │ 43.8mb │ deploy │ disabled │
│ 5 │ my-api │ default │ 1.0.0 │ cluster │ 14236 │ 0s │ 0 │ online │ 0% │ 42.5mb │ deploy │ disabled │
│ 6 │ my-api │ default │ 1.0.0 │ cluster │ 14243 │ 0s │ 0 │ online │ 0% │ 43.1mb │ deploy │ disabled │
│ 7 │ my-api │ default │ 1.0.0 │ cluster │ 14250 │ 0s │ 0 │ online │ 0% │ 42.9mb │ deploy │ disabled │
└────┘──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
Eight instances sharing port 3000. Hit the / endpoint multiple times and you will see different PIDs responding — PM2 round-robins requests across workers. No sticky sessions, no code changes, no manual cluster.fork() calls.
One important caveat: cluster mode works with TCP/HTTP servers. If your application is a background worker or cron job, use fork mode with instances: 1.
Scaling on the Fly
You can scale up or down without restarting:
# Scale to 4 instances
pm2 scale my-api 4
# Add 2 more instances
pm2 scale my-api +2
Log Management
PM2 captures stdout and stderr from every managed process and writes them to log files. By default, logs go to ~/.pm2/logs/.
# View all logs in real time
pm2 logs
# View logs for a specific app
pm2 logs my-api
# View last 200 lines
pm2 logs my-api --lines 200
# Flush all logs (truncate to zero)
pm2 flush
Log Rotation with pm2-logrotate
Out of the box, PM2 logs grow unbounded. On a busy production server, you can easily hit multiple gigabytes per day. The pm2-logrotate module handles this:
pm2 install pm2-logrotate
Configure it:
# Rotate when log file exceeds 50MB
pm2 set pm2-logrotate:max_size 50M
# Keep 30 rotated files
pm2 set pm2-logrotate:retain 30
# Enable compression of rotated files
pm2 set pm2-logrotate:compress true
# Rotate on a schedule (daily at midnight)
pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
# Force rotation every 24 hours regardless of size
pm2 set pm2-logrotate:rotateInterval "0 0 * * *"
Verify the configuration:
pm2 conf pm2-logrotate
Module: pm2-logrotate
max_size: 50M
retain: 30
compress: true
dateFormat: YYYY-MM-DD_HH-mm-ss
rotateInterval: 0 0 * * *
workerInterval: 30
rotateModule: true
In the ecosystem file, you can customize where logs go per application:
{
name: "my-api",
script: "./app.js",
error_file: "/var/log/myapp/error.log",
out_file: "/var/log/myapp/access.log",
log_date_format: "YYYY-MM-DD HH:mm:ss.SSS",
merge_logs: true
}
The merge_logs: true option is critical in cluster mode. Without it, PM2 creates separate log files for each worker instance (app-0.log, app-1.log, etc.). With merge_logs, all workers write to the same file, which is almost always what you want for centralized log processing.
Monitoring with PM2 Monit and PM2 Plus
Terminal Monitoring
pm2 monit opens an interactive dashboard in your terminal:
pm2 monit
This shows real-time CPU usage, memory consumption, loop delay, and active request counts for each process. It is useful for quick debugging sessions but not for persistent monitoring.
For a quick snapshot without the interactive UI:
pm2 status
PM2 Plus (Web Dashboard)
PM2 Plus is the paid monitoring service from the PM2 team. It provides a web dashboard with:
- Historical CPU and memory metrics
- Transaction tracing (like a lightweight APM)
- Exception tracking with stack traces
- Deployment notifications
- Custom metrics and actions
Link your PM2 instance to PM2 Plus:
pm2 plus
This opens a browser for authentication and links your server. After linking, you will see your processes in the web dashboard at app.pm2.io.
Custom Metrics
You can push custom metrics from your application using the @pm2/io module:
var pmx = require("@pm2/io");
var requestCounter = pmx.counter({
name: "Realtime request count",
id: "app/requests/count"
});
var responseTime = pmx.histogram({
name: "Response time",
id: "app/response/time",
measurement: "mean"
});
app.use(function (req, res, next) {
var start = Date.now();
requestCounter.inc();
res.on("finish", function () {
var duration = Date.now() - start;
responseTime.update(duration);
});
next();
});
These metrics show up in pm2 monit and in the PM2 Plus dashboard. It is a lightweight alternative to full APM tools like Datadog or New Relic when you need basic observability without the cost.
Zero-Downtime Deployments
This is where PM2 earns its keep. The difference between restart and reload is the difference between downtime and no downtime.
pm2 restart vs pm2 reload
pm2 restart kills all workers simultaneously and starts new ones. During the gap between kill and ready, incoming requests get connection refused errors. This is a hard restart.
pm2 reload performs a rolling restart. It starts a new worker, waits for it to signal readiness, then kills one old worker. It repeats this for each worker in the cluster. At no point are zero workers available to handle requests.
# Hard restart — causes brief downtime
pm2 restart my-api
# Graceful reload — zero downtime
pm2 reload my-api
For reload to work properly, your application needs to handle the shutdown signal gracefully:
var express = require("express");
var app = express();
var server;
app.get("/", function (req, res) {
res.json({ status: "ok" });
});
server = app.listen(process.env.PORT || 3000, function () {
console.log("Worker " + process.pid + " ready");
});
process.on("SIGINT", function () {
console.log("Worker " + process.pid + " received SIGINT, shutting down gracefully...");
server.close(function () {
console.log("Worker " + process.pid + " closed all connections");
// Close database connections, flush caches, etc.
process.exit(0);
});
// Force shutdown after timeout
setTimeout(function () {
console.error("Worker " + process.pid + " forced shutdown after timeout");
process.exit(1);
}, 10000);
});
The kill_timeout in your ecosystem file controls how long PM2 waits before sending SIGKILL after SIGINT. Set it higher than your application's shutdown timeout:
{
kill_timeout: 15000, // 15 seconds to shut down gracefully
listen_timeout: 10000 // 10 seconds to start and signal ready
}
Ready Signal
By default, PM2 considers a process "ready" as soon as the script executes. For applications that need to connect to databases or load caches before accepting traffic, use the ready signal:
var express = require("express");
var mongoose = require("mongoose");
var app = express();
mongoose.connect(process.env.MONGO_URI, function (err) {
if (err) {
console.error("MongoDB connection failed:", err);
process.exit(1);
}
app.listen(3000, function () {
console.log("Worker " + process.pid + " ready");
// Tell PM2 this process is ready to accept connections
if (process.send) {
process.send("ready");
}
});
});
In your ecosystem file, enable the wait-ready pattern:
{
wait_ready: true,
listen_timeout: 15000 // How long to wait for the 'ready' signal
}
Now pm2 reload will not route traffic to a new worker until it has connected to MongoDB and sent the ready signal.
Startup Scripts
Your server will reboot. Power failure, kernel update, provider maintenance — it happens. Without a startup script, your PM2-managed applications will not restart after a reboot.
# Generate a startup script for your init system (systemd, upstart, launchd)
pm2 startup
# PM2 will print a command like this:
# sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deploy
# Run the command it prints, then save your current process list:
pm2 save
pm2 startup detects your init system and generates the appropriate configuration. On modern Linux, this is a systemd service. On macOS, it is a launchd plist.
pm2 save serializes your current running process list to ~/.pm2/dump.pm2. On boot, PM2 reads this file and starts everything that was running before the reboot.
If you change your process list (add or remove apps), run pm2 save again:
pm2 start new-service.js --name new-service
pm2 save
To remove the startup script:
pm2 unstartup systemd
Environment Management
The ecosystem file supports multiple environment configurations through env_* properties:
module.exports = {
apps: [
{
name: "my-api",
script: "./app.js",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "development",
PORT: 3000,
DB_HOST: "localhost",
LOG_LEVEL: "debug"
},
env_staging: {
NODE_ENV: "staging",
PORT: 8080,
DB_HOST: "staging-db.internal",
LOG_LEVEL: "info"
},
env_production: {
NODE_ENV: "production",
PORT: 8080,
DB_HOST: "prod-db.internal",
LOG_LEVEL: "warn"
}
}
]
};
Start with a specific environment:
# Uses env (development)
pm2 start ecosystem.config.js
# Uses env_staging
pm2 start ecosystem.config.js --env staging
# Uses env_production
pm2 start ecosystem.config.js --env production
One thing that catches people: environment variables are frozen at start time. If you change env_production in the ecosystem file and run pm2 restart, the old environment variables persist. You need to delete and re-start:
pm2 delete my-api
pm2 start ecosystem.config.js --env production
Or use the --update-env flag:
pm2 restart my-api --update-env
Watch Mode for Development
During development, you want automatic restarts when files change. PM2 has a built-in watch mode:
pm2 start app.js --watch
In the ecosystem file:
{
name: "my-api",
script: "./app.js",
watch: true,
ignore_watch: [
"node_modules",
"logs",
".git",
"uploads",
"*.log"
],
watch_options: {
followSymlinks: false,
usePolling: true,
interval: 1000
}
}
The ignore_watch array is critical. Without it, PM2 watches node_modules, which triggers thousands of unnecessary restarts during npm install and uses excessive inotify watchers.
I want to be clear: watch mode is for development only. Do not run watch mode in production. File system watchers consume resources, and accidental file changes (log writes, temp files) can cause unexpected restarts. In production, use explicit pm2 reload as part of your deployment pipeline.
PM2 Deploy System for Remote Deployments
PM2 includes a built-in deployment system that handles SSH-based deploys to remote servers. It is not as sophisticated as Ansible or Capistrano, but for simple Node.js deployments it gets the job done.
Add a deploy section to your ecosystem file:
module.exports = {
apps: [
{
name: "my-api",
script: "./app.js",
instances: "max",
exec_mode: "cluster",
env_production: {
NODE_ENV: "production",
PORT: 8080
}
}
],
deploy: {
production: {
user: "deploy",
host: ["159.65.100.50"],
ref: "origin/master",
repo: "[email protected]:yourorg/my-api.git",
path: "/home/deploy/my-api",
"pre-deploy-local": "",
"post-deploy": "npm install && pm2 reload ecosystem.config.js --env production",
"pre-setup": "",
ssh_options: "StrictHostKeyChecking=no"
},
staging: {
user: "deploy",
host: "159.65.100.51",
ref: "origin/develop",
repo: "[email protected]:yourorg/my-api.git",
path: "/home/deploy/my-api",
"post-deploy": "npm install && pm2 reload ecosystem.config.js --env staging",
ssh_options: "StrictHostKeyChecking=no"
}
}
};
Set up the remote server:
pm2 deploy production setup
This SSHs into the server, creates the directory structure, and clones the repo. Then deploy:
pm2 deploy production
This pulls the latest code, runs post-deploy (which installs dependencies and reloads PM2), and your application is updated with zero downtime.
Other useful deploy commands:
# Deploy a specific commit
pm2 deploy production ref abc123f
# Rollback to the previous deployment
pm2 deploy production revert 1
# Execute a command on the remote server
pm2 deploy production exec "pm2 logs"
# View current deployment info
pm2 deploy production curr
The deployment system uses a current, source, and shared directory structure similar to Capistrano. It keeps previous deployments for rollback.
PM2 vs Docker vs systemd
This is a question I get asked frequently, and the answer depends on your infrastructure.
PM2 Alone
Best for: Single-server deployments, small teams, VPS hosting, DigitalOcean Droplets.
PM2 handles process management, clustering, logging, and deployment all in one tool. If you are running one or two Node.js applications on a single server, PM2 is the fastest path to a production-grade setup.
# Full production setup in 4 commands
pm2 start ecosystem.config.js --env production
pm2 startup
pm2 save
pm2 install pm2-logrotate
Docker + PM2
Some people run PM2 inside Docker containers. I do not recommend this in most cases. Docker already provides process restart policies (restart: always), and Kubernetes or Docker Swarm handle clustering and load balancing. Running PM2 inside Docker adds a process manager inside a process manager.
The one exception is if you want PM2's cluster mode inside a container that has been allocated multiple CPU cores:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm install -g pm2
EXPOSE 8080
CMD ["pm2-runtime", "ecosystem.config.js", "--env", "production"]
Note the use of pm2-runtime instead of pm2 start. The pm2-runtime command is designed for containers — it runs in the foreground (so Docker does not think the process exited) and properly handles Docker's SIGTERM signals.
systemd + Node.js
systemd is the native init system on modern Linux. It can manage Node.js processes directly:
[Unit]
Description=My Node.js API
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/my-api
ExecStart=/usr/bin/node app.js
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=my-api
Environment=NODE_ENV=production PORT=8080
[Install]
WantedBy=multi-user.target
systemd gives you process restart and log management (via journald), but no cluster mode, no ecosystem file, no zero-downtime reload, and no built-in deployment system. If you are already managing infrastructure with Ansible and have standardized on systemd, it works. But you will need to handle clustering yourself with the Node.js cluster module.
My Recommendation
For most Node.js teams, PM2 on a VPS is the right starting point. When you outgrow a single server and need container orchestration, move to Docker with Kubernetes or Docker Swarm and drop PM2. The middle ground of PM2-inside-Docker adds complexity without clear benefits.
Complete Working Example
Here is a production-ready setup for an Express.js API with cluster mode, log rotation, environment-specific configs, graceful shutdown, and a deploy script.
Application (app.js)
var express = require("express");
var os = require("os");
var app = express();
var PORT = process.env.PORT || 3000;
var server;
app.use(express.json());
// Health check endpoint for load balancers
app.get("/health", function (req, res) {
res.status(200).json({
status: "healthy",
pid: process.pid,
uptime: Math.floor(process.uptime()) + "s",
memory: Math.round(process.memoryUsage().rss / 1024 / 1024) + "MB",
environment: process.env.NODE_ENV || "development"
});
});
// Main route
app.get("/", function (req, res) {
res.json({
message: "API is running",
worker: process.pid,
hostname: os.hostname(),
cores: os.cpus().length
});
});
// Simulate work
app.get("/api/data", function (req, res) {
var data = [];
for (var i = 0; i < 100; i++) {
data.push({
id: i,
timestamp: new Date().toISOString(),
worker: process.pid
});
}
res.json(data);
});
// Start server
server = app.listen(PORT, function () {
console.log("[" + new Date().toISOString() + "] Worker " + process.pid + " listening on port " + PORT);
// Signal PM2 that this process is ready
if (process.send) {
process.send("ready");
}
});
// Graceful shutdown
process.on("SIGINT", function () {
console.log("[" + new Date().toISOString() + "] Worker " + process.pid + " received SIGINT");
server.close(function () {
console.log("[" + new Date().toISOString() + "] Worker " + process.pid + " HTTP server closed");
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(function () {
console.error("[" + new Date().toISOString() + "] Worker " + process.pid + " forced exit");
process.exit(1);
}, 10000);
});
Ecosystem File (ecosystem.config.js)
module.exports = {
apps: [
{
name: "my-api",
script: "./app.js",
// Cluster configuration
instances: "max",
exec_mode: "cluster",
// Memory management
max_memory_restart: "500M",
node_args: "--max-old-space-size=512",
// Restart behavior
restart_delay: 4000,
max_restarts: 10,
min_uptime: "10s",
autorestart: true,
// Graceful shutdown
kill_timeout: 15000,
listen_timeout: 10000,
wait_ready: true,
shutdown_with_message: false,
// Logging
log_date_format: "YYYY-MM-DD HH:mm:ss.SSS",
error_file: "./logs/my-api-error.log",
out_file: "./logs/my-api-out.log",
merge_logs: true,
log_type: "json",
// Watch (development only — set to false for production)
watch: false,
// Environment variables
env: {
NODE_ENV: "development",
PORT: 3000,
LOG_LEVEL: "debug"
},
env_staging: {
NODE_ENV: "staging",
PORT: 8080,
LOG_LEVEL: "info"
},
env_production: {
NODE_ENV: "production",
PORT: 8080,
LOG_LEVEL: "warn"
}
}
],
deploy: {
production: {
user: "deploy",
host: ["159.65.100.50"],
ref: "origin/master",
repo: "[email protected]:yourorg/my-api.git",
path: "/home/deploy/my-api",
"pre-deploy": "git fetch --all",
"post-deploy": "npm ci --production && pm2 reload ecosystem.config.js --env production && pm2 save",
"pre-setup": "apt-get install -y git",
ssh_options: ["StrictHostKeyChecking=no", "PasswordAuthentication=no"]
},
staging: {
user: "deploy",
host: "159.65.100.51",
ref: "origin/develop",
repo: "[email protected]:yourorg/my-api.git",
path: "/home/deploy/my-api",
"post-deploy": "npm ci && pm2 reload ecosystem.config.js --env staging && pm2 save",
ssh_options: ["StrictHostKeyChecking=no", "PasswordAuthentication=no"]
}
}
};
Setup Script (setup.sh)
#!/bin/bash
# Production server setup script
set -e
echo "=== PM2 Production Setup ==="
# Create logs directory
mkdir -p logs
# Install dependencies
npm ci --production
# Install PM2 globally if not present
if ! command -v pm2 &> /dev/null; then
echo "Installing PM2..."
npm install -g pm2
fi
# Install log rotation module
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 30
pm2 set pm2-logrotate:compress true
pm2 set pm2-logrotate:rotateInterval "0 0 * * *"
# Start application with production environment
pm2 start ecosystem.config.js --env production
# Set up startup script
pm2 startup
echo ">>> Run the command printed above with sudo, then run: pm2 save"
# Save process list
pm2 save
echo "=== Setup complete ==="
pm2 list
Deploy Workflow
# One-time setup on the remote server
pm2 deploy production setup
# Deploy latest master to production
pm2 deploy production
# Rollback if something goes wrong
pm2 deploy production revert 1
# Check production status
pm2 deploy production exec "pm2 status"
# View production logs
pm2 deploy production exec "pm2 logs --lines 50"
Common Issues and Troubleshooting
1. Port Already in Use After Restart
Error: listen EADDRINUSE: address already in use :::3000
at Server.setupListenHandle [as _setupListenHandle] (net.js:1380:16)
This happens when PM2 starts a new worker before the old one has released the port. Increase kill_timeout in your ecosystem file and make sure your application handles SIGINT properly by calling server.close(). If the problem persists, the old process is stuck. Kill it manually:
lsof -i :3000
kill -9 <PID>
pm2 restart my-api
2. Cluster Mode Fails with "Script not found"
[PM2][ERROR] Script not found: /home/deploy/my-api/app.js
PM2 resolves the script path relative to where you run the command, not relative to the ecosystem file. Always use absolute paths or ./ relative paths in the ecosystem file, and run pm2 start from the project root. Alternatively, add cwd to your ecosystem config:
{
name: "my-api",
script: "./app.js",
cwd: "/home/deploy/my-api"
}
3. Environment Variables Not Updating
You changed env_production in the ecosystem file and ran pm2 restart my-api, but your app still sees the old values. PM2 caches environment variables. You need to either delete and re-start or use --update-env:
# Option 1: Delete and restart
pm2 delete my-api
pm2 start ecosystem.config.js --env production
# Option 2: Restart with update flag
pm2 restart my-api --update-env
4. Crash Loop: "PM2 has reached the maximum number of restarts"
[PM2] Script app.js had too many unstable restarts (10). Stopped.
Your application is crashing within min_uptime (default 1 second) of starting. PM2 detects this as an "unstable restart" and stops trying after max_restarts. Check the logs for the actual crash:
pm2 logs my-api --lines 100 --err
Common causes: missing environment variables, database connection failures, port conflicts. Fix the underlying issue, then:
pm2 delete my-api
pm2 start ecosystem.config.js --env production
5. Memory Keeps Growing Until max_memory_restart Triggers
[PM2] Process 0 restarted because it exceeds --max-memory-restart value (500M)
This is PM2 doing its job, but it is a symptom of a memory leak in your application. PM2 is a safety net, not a fix. Use pm2 monit to watch memory growth over time. If it climbs steadily, you have a leak. Common Node.js leak sources: unclosed database connections, growing caches without eviction, event listeners that are never removed, and closures holding references to large objects.
Profile the leak:
node --inspect app.js
# Open chrome://inspect in Chrome
# Take heap snapshots over time and compare
6. Startup Script Not Working After Server Reboot
pm2 list
# Shows empty — no processes running
You ran pm2 startup but forgot pm2 save, or you ran them as different users. The startup script runs as the user specified in the systemd service. Make sure:
# Generate startup script (note the user)
pm2 startup systemd -u deploy --hp /home/deploy
# Save process list as the SAME user
pm2 save
# Verify the dump file exists
ls -la /home/deploy/.pm2/dump.pm2
Best Practices
Always use an ecosystem file. Command-line flags are not repeatable or version-controllable. Commit
ecosystem.config.jsto your repository so every team member and every server runs the same configuration.Set
max_memory_restarton every production application. Memory leaks are inevitable in long-running Node.js processes. A 500 MB limit with automatic restart is better than an OOM kill from the kernel that takes your entire server down.Use
pm2 reload, neverpm2 restart, in production. The only exception is when you need to pick up new environment variables with--update-env. Even then, considerpm2 deletefollowed bypm2 startto be explicit.Handle SIGINT in your application code. Without a graceful shutdown handler, PM2's reload will kill your workers mid-request. Close the HTTP server, drain connections, close database pools, then exit.
Install
pm2-logrotatebefore you need it. A production server that runs out of disk space because of unrotated PM2 logs is an embarrassing outage. Set it up on day one.Run
pm2 saveafter every change to your process list. Adding a new app?pm2 save. Removing an old one?pm2 save. Changing instance count?pm2 save. Otherwise your next reboot will restore the old process list.Use
wait_ready: truewithprocess.send("ready")for applications with slow startup. Database connections, cache warming, and configuration loading all take time. Do not route traffic to a worker that is not ready.Do not use PM2's watch mode in production. It is a development convenience. Log file writes, temp files, and deployment artifacts will trigger unnecessary restarts. Use explicit
pm2 reloadin your CI/CD pipeline.Set
min_uptimeandmax_restartsto prevent crash loops. A default of"10s"and10restarts is reasonable. Without these, a crashing application will restart infinitely, consuming CPU and flooding logs.Pin your PM2 version in production. Install a specific version (
npm install -g [email protected]) rather than always getting the latest. PM2 major version upgrades can change behavior. Test upgrades in staging first.
