A naive deploy — git pull, npm install, restart — causes a few seconds of 502 errors while the app restarts, and longer if the install or build fails halfway. With a few techniques, Node.js apps on a single VPS can be updated with no dropped requests.

1. Graceful shutdown in the app

Everything else depends on this: when told to stop, the app must stop accepting new connections, finish in-flight requests, then exit.

const server = app.listen(process.env.PORT || 3000, () => {
  process.send?.('ready');           // tell PM2 we're ready (wait_ready)
});

function shutdown() {
  server.close(async () => {          // stop accepting, wait for open requests
    await db.end?.();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref();   // hard limit
}

process.on('SIGINT', shutdown);       // PM2 sends SIGINT by default
process.on('SIGTERM', shutdown);      // systemd and Docker send SIGTERM

Keep-alive connections can hold server.close() open; Node 18.2+ offers server.closeIdleConnections() to close idle ones promptly.

2. PM2 cluster mode and reload

Run several instances in cluster mode so they can be restarted one at a time:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'api',
    script: 'dist/server.js',
    cwd: '/srv/api/current',
    instances: 2,                 // at least 2 for rolling reloads
    exec_mode: 'cluster',
    wait_ready: true,             // wait for process.send('ready')
    listen_timeout: 10000,
    kill_timeout: 12000,
    env: { NODE_ENV: 'production', PORT: 3000 },
  }],
};
pm2 reload ecosystem.config.js --update-env

reload replaces instances one by one: start a new one, wait for ready, then gracefully stop an old one. There’s always an instance serving traffic. (restart stops everything at once — avoid it for deploys.) See the PM2 guide.

3. Build in a separate release directory

Never build in the directory the running app uses — a failed npm ci or build leaves a broken app. Use releases and an atomic symlink switch:

/srv/api/
├── releases/
│   ├── 20261103120000/
│   └── 20261104093000/
├── shared/.env
└── current -> releases/20261104093000
#!/bin/bash
set -euo pipefail
APP=/srv/api
REL=$APP/releases/$(date +%Y%m%d%H%M%S)

git clone --depth 1 --branch main [email protected]:org/api.git "$REL"
cd "$REL"
ln -s $APP/shared/.env .env
npm ci
npm run build
npm prune --omit=dev

ln -sfn "$REL" $APP/current           # atomic switch
pm2 reload $APP/current/ecosystem.config.js --update-env

ls -1dt $APP/releases/* | tail -n +6 | xargs rm -rf   # keep 5 releases

If anything fails before the ln -sfn, the live app is untouched. Rollback = point current at the previous release and reload.

PM2 resolves cwd when processes start; using the current symlink path and reloading ensures new instances pick up the new release.

4. Health check before declaring success

After reloading, verify:

for i in $(seq 1 20); do
  curl -fsS http://127.0.0.1:3000/health && exit 0
  sleep 1
done
echo "Health check failed — rolling back"
ln -sfn "$(ls -1dt /srv/api/releases/* | sed -n 2p)" /srv/api/current
pm2 reload /srv/api/current/ecosystem.config.js
exit 1

5. Nginx in front: retry on errors

If a request hits an instance at the exact moment it goes away, Nginx can retry another upstream:

upstream api {
    server 127.0.0.1:3000;
    keepalive 16;
}

location / {
    proxy_pass http://api;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_next_upstream error timeout http_502;
    proxy_next_upstream_tries 2;
}

With PM2 cluster mode, all instances share port 3000, so this mainly covers edge cases. With separate processes on different ports (or blue-green), list them all in the upstream.

6. Database migrations

Old and new code run side by side for a few seconds during a reload. Migrations must be backward compatible: add columns before using them; remove or rename only in a later release. Run migrations before the reload in the deploy script.

7. Docker alternative

With containers: start the new container alongside the old one (different name/port), health-check it, switch the reverse proxy (Traefik and Caddy can do this with labels or config reloads), then stop the old one. docker compose up -d alone recreates containers with a short gap.

Checklist

  • App handles SIGINT/SIGTERM gracefully
  • At least 2 instances (PM2 cluster or two processes)
  • reload, not restart
  • Builds in a new release directory; symlink switch
  • Health check with automatic rollback
  • Backward-compatible migrations
  • Deploy from CI — see GitHub Actions to a VPS
  • Node.js
  • Deployment
  • PM2