Skip to main content

Node.js zero-downtime deployment on Linux

TL;DR

How to achieve zero-downtime deployments for Node.js using PM2 cluster mode with graceful reload and health verification.

Key facts

Topology
Node.js + PM2 cluster

TL;DR

Zero-downtime deployment for Node.js means deploying new code without dropping a single request. This requires PM2's cluster mode, a graceful reload strategy, and health verification between steps.

How PM2 cluster reload works

When you run pm2 reload, PM2:

  1. Starts a new worker with the updated code
  2. Waits for it to signal "ready"
  3. Sends the old worker a SIGINT to begin graceful shutdown
  4. The old worker stops accepting new connections and drains in-flight requests
  5. Repeats for each worker in the cluster

This rolling reload means at least one worker is always handling traffic.

Application requirements

Your app must signal readiness to PM2 and handle graceful shutdown:

const server = app.listen(PORT, () => {
  if (process.send) {
    process.send('ready');
  }
});

process.on('SIGINT', () => {
  server.close(() => {
    process.exit(0);
  });
});

Enable wait-ready in your ecosystem config:

module.exports = {
  apps: [{
    name: 'api',
    script: 'server.js',
    instances: 'max',
    exec_mode: 'cluster',
    wait_ready: true,
    listen_timeout: 10000,
    kill_timeout: 5000,
  }],
};

Deployment script

A safe deployment sequence:

#!/bin/bash
set -euo pipefail

APP_DIR="/var/www/myapp"
cd "$APP_DIR"

git fetch origin main
git reset --hard origin/main

npm ci --production
npm run build

pm2 reload ecosystem.config.js --env production

sleep 3
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health)
if [ "$HTTP_CODE" != "200" ]; then
  echo "Health check failed — rolling back"
  git reset --hard HEAD~1
  npm ci --production
  pm2 reload ecosystem.config.js --env production
  exit 1
fi

echo "Deployment complete"

nginx considerations

If nginx is caching upstream connections with keepalive, the reload is seamless. Ensure proxy_http_version 1.1 and Connection "" headers are set so nginx reuses connections to the new workers.

Common pitfalls

  • Missing wait_ready: PM2 sends traffic to the new worker before it finishes initialising
  • Missing SIGINT handler: The old worker is killed abruptly, dropping in-flight requests
  • Database migrations: Run migrations before reloading workers, and ensure they are backward-compatible with the current running code

With Reflex

Reflex orchestrates zero-downtime deploys end-to-end: it pulls your code, runs the build, executes a rolling PM2 reload, runs health checks, and automatically rolls back if the health check fails — all without SSH access or manual scripting. See How it works.