TL;DR — Zero-downtime Node.js deploys need four things working together, and three of them are usually missing. Run the app in PM2 cluster mode so instance count never hits zero. Deploy with
pm2 reload, notpm2 restart— reload replaces workers one at a time, restart kills them all at once. HandleSIGINTin the app so a dying worker stops accepting connections and finishes its in-flight queries instead of dropping them mid-transaction. And setproxy_next_upstreamin NGINX so a request that lands on a worker mid-swap gets retried on a sibling rather than returning a 502. Miss the SIGINT handler and “zero-downtime” just moves the dropped requests somewhere you stop seeing them.
When a platform processes meal orders across busy institutional cafeterias, downtime isn’t just inconvenient - people don’t get fed. High availability is a hard operational requirement, not just a product target.
The original deployment process was: SSH into the server, git pull, npm install, and pm2 restart all. This caused a 10–30 second outage per deploy, during which the API dropped orders and hung terminals.
Here is exactly how I set up graceful, zero-downtime deployments for a Node.js server cluster using PM2’s native clustering and NGINX upstream proxies.
1. PM2 Cluster Configuration
The foundation of zero-downtime is simple: never let the number of active app instances drop to zero. PM2’s cluster mode makes it straightforward to scale your process across all cores:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'orders-api',
script: './dist/server.js',
instances: 'max', // Scale to all available CPU cores
exec_mode: 'cluster',
max_memory_restart: '500M',
listen_timeout: 10000, // Wait 10s for boot signal
kill_timeout: 5000 // Wait 5s for clean close
}]
};
instances: 'max': Spawns a process on each CPU core.listen_timeout: Instructs PM2 to wait for a database connection or socket handshake before marking a new process as “Online.”kill_timeout: Gives active processes 5 seconds to wrap up in-flight REST queries before forcing a close.
2. Transitioning to Graceful Reloads
Instead of calling pm2 restart (which kills all instances simultaneously), we transition to pm2 reload.
Reload initiates a rolling update: it spawns a new instance, waits for it to become online, then safely turns down an old instance. It repeats this pattern process-by-process, maintaining maximum API capacity:
# Production deploy script (deploy.sh)
#!/bin/bash
set -e
echo "Pulling latest branch code..."
git pull origin main
echo "Installing production-only dependencies..."
npm ci --production
echo "Executing rolling reload..."
pm2 reload ecosystem.config.js --update-env
echo "Deploy successfully completed!"
3. Implementing Application Graceful Shutdowns
PM2 sends a SIGINT trigger to your process before shutting it down. This is the same process signal handling Node.js exposes for any termination signal. If your application doesn’t handle this signal, it terminates instantly, dropping all connections mid-transaction.
You must catch the SIGINT event, close the HTTP port to block new inbound traffic, finish active queries, and release database pools:
// ✅ Professional graceful shutdown hook in server.js
process.on('SIGINT', () => {
console.log('SIGINT signal received. Starting graceful shutdown sequence...');
// Stop the HTTP server from accepting new socket sessions
server.close(async () => {
console.log('HTTP server successfully closed.');
try {
// Release database connection pools cleanly
await db.end();
console.log('Database pools released. Exiting cleanly.');
process.exit(0);
} catch (err) {
console.error('Error during database teardown:', err);
process.exit(1);
}
});
// Force close after a 6-second timeout block if connections hang
setTimeout(() => {
console.warn('Forced shutdown active: connections did not close in time.');
process.exit(1);
}, 6000);
});
4. Configuring NGINX Load-Balancing
NGINX is our front-door gateway. The key configurations for zero-downtime routing include setting up an upstream pool and instructing NGINX to pass traffic to active processes on failures:
upstream orders_backend {
server 127.0.0.1:3000;
keepalive 64; # Keep connection channels open to reduce latency
}
server {
listen 443 ssl http2;
server_name api.example.com;
location / {
proxy_pass http://orders_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 🚀 Crucial: if one instance is reloading, try next active worker
proxy_next_upstream error timeout http_502 http_503;
proxy_connect_timeout 3s;
proxy_read_timeout 15s;
}
}
By adding proxy_next_upstream, if an instance is in the middle of reloading and drops a connection, NGINX instantly retries the query on a sibling process. The client has zero awareness of the deploy event.
5. Webhook CI/CD Automation
To secure execution, we set up a lightweight deployment daemon on our EC2 instance that exposes a secured webhook route. When a PR merges into main on GitHub, our actions pipeline runs tests, builds the typescript bundle, and pings the webhook:
# GitHub Actions CI deployment step
- name: Trigger Server Deploy Webhook
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.DEPLOY_SECRET }}" \
https://api.example.com/webhooks/deploy
The daemon checks the bearer token (a shared secret) in constant time before
running anything. This triggers the automated deploy.sh script locally on the server.
The Outcome
- 0 seconds of user-facing downtime recorded.
- 45-second deployment pipeline from git merge to production availability.
- Confidence to deploy minor changes and hotfixes safely during standard hours.
What I’d still improve
The reload is graceful, but the database isn’t part of it. A migration that drops a column is still a hard break: old workers and new workers are briefly running at the same time against the same schema, so any deploy that changes the schema has to be split into expand-then-contract steps that I currently do by hand. I also have no automated rollback — if a reload ships a bad build, the fix is another deploy, which is fine at 45 seconds and would not be at five minutes.
FAQ
What is the difference between pm2 restart and pm2 reload? Restart kills every worker at once, so there is a window with zero instances serving. Reload performs a rolling replacement — start a new worker, wait for it to come online, retire an old one, repeat — so capacity never drops to zero.
Is cluster mode enough for zero-downtime deploys? No. Cluster mode keeps instance count above zero, but a worker being retired still drops whatever it was mid-way through unless the application handles the shutdown signal. Cluster mode plus reload without a graceful shutdown just relocates the dropped requests.
What does a graceful shutdown handler need to do? Stop accepting new connections, finish in-flight requests and open transactions, close database and cache connections, then exit. The process manager gives you a bounded window before it forces the close, so the handler has to finish inside it.
Why do requests still 502 during a deploy? Because a request can land on a worker in the moment it is being retired. Configuring the reverse proxy to retry that request against a healthy sibling turns a user-visible error into an invisible retry.
Should database migrations run during a zero-downtime deploy? Only expand-and-contract ones. While the reload is rolling, old and new code are serving simultaneously, so the schema has to satisfy both. Add columns first, backfill, switch reads, and only drop the old shape in a later deploy.
How do you verify a deploy was actually zero-downtime? Run continuous traffic against the service during the deploy and count non-2xx responses. If you are only checking that the process came back up, you are measuring process health rather than request success.
The one idea to take away
Zero-downtime is not a PM2 flag, it’s a handshake. The process manager has to wait for the new worker to be genuinely ready, the app has to refuse new work and drain the old worker’s in-flight requests, and the proxy has to retry anything caught in between. Implement one of those three and you have not removed the dropped requests — you have only stopped being able to see them.
If you’re tightening a production Node.js deployment, the neighbouring problems are usually request-cycle performance and the mistakes that show up as you scale.