Skip to content

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


next -> deploy_03_ci_cd.md