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, notrestart - 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