PM2 Process Manager¶
Your app will crash
Not if , but when. A process manager catches the corpse and spawns a new one before your users notice the blip PM2 is the standard for Node.js process management - it handles restarts , clustering , logs , and startup scripts without you writing a monitoring loop from scratch
Installing PM2¶
Global install , one line , done
npm install -g pm2
# Verify
pm2 --version
# 5.3.x or later
For CI/CD pipelines where global installs are messy , use npx pm2 or install as a project dependency:
npm install --save-dev pm2
npx pm2 list
Starting Apps with PM2¶
Three ways to start: direct command , ecosystem file , or npm script
# Basic start
pm2 start dist/index.js --name myapp
# With arguments
pm2 start dist/index.js --name myapp -- --port 8080 --env production
# Cluster mode - use all CPUs
pm2 start dist/index.js -i max --name myapp
# Specify instance count
pm2 start dist/index.js -i 4 --name myapp
# Watch for file changes (dev only - unsafe for prod)
pm2 start dist/index.js --watch --ignore-watch="node_modules"
The -i max flag spawns one process per CPU core On a 4-core server , you get 4 Node.js processes sharing port traffic through PM2's built-in load balancer More cores means more concurrent requests handled without extra code
Ecosystem File - The Right Way¶
CLI flags are fine for testing , but your production config belongs in a file
// ecosystem.config.js - version-controlled , environment-aware
module.exports = {
apps: [{
name: 'myapp-api',
script: 'dist/index.js',
instances: 'max',
exec_mode: 'cluster',
watch: false,
max_memory_restart: '1G',
error_file: '/var/log/myapp/error.log',
out_file: '/var/log/myapp/out.log',
merge_logs: true,
log_date_format: 'YYYY-MM-DD HH:mm:ss',
env: {
NODE_ENV: 'development',
PORT: 3000
},
env_production: {
NODE_ENV: 'production',
PORT: 8080,
NODE_OPTIONS: '--max-old-space-size=1024'
},
env_staging: {
NODE_ENV: 'production',
PORT: 8081,
NODE_OPTIONS: '--max-old-space-size=512'
}
}]
}
# Start with production config
pm2 start ecosystem.config.js --env production
# Or with specific environment
pm2 start ecosystem.config.js --env staging
The ecosystem file supports multiple apps if you run a monolith that needs separate worker processes - one for the API , one for background jobs
// Multiple apps in one file
module.exports = {
apps: [
{
name: 'api',
script: 'dist/api.js',
instances: 4,
exec_mode: 'cluster',
env_production: { PORT: 8080 }
},
{
name: 'worker',
script: 'dist/worker.js',
instances: 2,
exec_mode: 'fork',
env_production: { QUEUE_CONCURRENCY: 5 }
},
{
name: 'cron',
script: 'dist/cron.js',
instances: 1,
cron_restart: '0 0 * * *', // restart daily
autorestart: false
}
]
}
PM2 Commands - The Daily Drivers¶
# List all processes
pm2 list
# Monitor in real-time
pm2 monit
# Shows CPU , memory , logs per process - hits Ctrl-C to exit
# Show detailed process info
pm2 show myapp-api
# View logs
pm2 logs # all apps
pm2 logs myapp-api # specific app
pm2 logs --lines 100 # last 100 lines
pm2 logs --format # JSON format for parsing
# Stop/restart/delete
pm2 stop myapp-api
pm2 restart myapp-api
pm2 delete myapp-api
# Zero-downtime reload - restarts workers one by one
pm2 reload myapp-api
# Reload all apps from ecosystem file
pm2 reload ecosystem.config.js
The difference between restart and reload: restart kills all processes at once (downtime) , reload sends SIGINT to one process , waits for it to disconnect , starts a new one , then moves to the next (zero downtime)
Graceful Shutdown - Make Reload Actually Work¶
PM2 sends SIGINT on reload , but your app needs to handle it
// In your app - handle SIGINT for graceful shutdown
const server = app.listen(port, () => {
console.log(`Server listening on port ${port}`)
})
function gracefulShutdown(signal) {
console.log(`Received ${signal}, shutting down gracefully`)
server.close(() => {
console.log('Finished processing requests, shutting down')
// Close database connections
db.end()
// Close Redis
redis.quit()
process.exit(0)
})
// Force shutdown after 10 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout')
process.exit(1)
}, 10000).unref()
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
Without graceful shutdown , ongoing requests are killed mid-execution - database writes fail , responses timeout , users see 502s
Log Management¶
PM2 captures stdout/stderr and writes to log files
# Default log locations
~/.pm2/logs/
# app-out.log - stdout
# app-error.log - stderr
# View logs
pm2 logs myapp-api
pm2 logs myapp-api --raw # raw output , no formatting
pm2 flush myapp-api # clear logs
Log rotation - PM2 won't rotate logs forever , they'll fill your disk
# Install pm2-logrotate
pm2 install pm2-logrotate
# Configure
pm2 set pm2-logrotate:max_size 10M # rotate at 10MB
pm2 set pm2-logrotate:retain 7 # keep 7 files
pm2 set pm2-logrotate:compress true # gzip old logs
pm2 set pm2-logrotate:dateFormat YYYY-MM-DD
Without log rotation a busy app hits gigabytes in days On a small VPS , that fills the disk , crashes the app , and PM2 restarts it only to crash again - infinite crash loop from logs
Monitoring with PM2¶
# Real-time dashboard
pm2 monit
# Process list with resource usage
pm2 status
# Output:
#
# | id | name | mode | status | cpu | mem | uptime |
# |----|--------|------|--------|-----|------|--------|
# | 0 | api | fork | online | 2% | 45MB | 12d |
# | 1 | worker | fork | online | 0% | 28MB | 12d |
# Process descriptions - env vars , restart count , logs , pid
pm2 describe myapp-api
# Memory usage per process
pm2 prettylist
Keymetrics - PM2's commercial monitoring platform
- Real-time metrics dashboard - Error notifications - Custom metrics from your app - Deployment history
Free tier exists and is fine for small teams , but for production monitoring you'll want something more robust (see deploy_05)
PM2 in Docker¶
PM2 inside Docker is debatable - Docker's restart policies overlap with PM2's restart behavior
Option 1: PM2-runtime (official) - replaces PM2 for containerized apps
# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 8080
# Use pm2-runtime instead of pm2
CMD ["pm2-runtime", "ecosystem.config.js", "--env", "production"]
pm2-runtime forwards signals properly to the child process - regular pm2 inside a container traps SIGTERM and breaks Docker's stop/restart cycle
Option 2: Plain node with Docker restart - simpler , less overhead
# Dockerfile
FROM node:22-alpine
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "dist/index.js"]
# Docker compose - restart policy handles crashes
services:
app:
build: .
restart: unless-stopped
deploy:
replicas: 4 # Docker swarm replicas replace PM2 clustering
If you're using Kubernetes , skip PM2 entirely - k8s handles pod restarts , scaling , and health checks PM2 adds nothing and complicates container signal handling
PM2 Security¶
- Run with correct user - don't run PM2 as root unless you absolutely need to bind to privileged ports (under 1024) Even then , use authbind or iptables redirect instead
# Create a dedicated user
sudo useradd --system --create-home --shell /bin/false nodeapp
sudo -u nodeapp pm2 start ecosystem.config.js --env production
-
Set max memory limits - prevent a memory leak from taking down the server
max_memory_restart: '1G'in your ecosystem file -
Restrict log access - logs may contain sensitive data (request bodies , error traces with env vars) Set log file permissions:
chmod 640 /var/log/myapp/*.log -
Don't expose PM2's web interface - PM2 Plus/Keymetrics web port is internal only , never expose it to the internet
# Bad - exposes PM2 dashboard to everyone
# pm2 web (opens port 9615 on all interfaces)
# Good - bind to localhost only if you must use it
pm2 web --only localhost
Prerequisites¶
- deploy_01_env_setup.md - environment configuration before process management
next -> deploy_03_ci_cd.md