Here’s an uncomfortable admission for a post filed under “zero-downtime deploys”: the app I’m going to use as the real example doesn’t have zero-downtime deploys yet. It’s an internal ticketing tool I run on a small container — Node 22, Express, Socket.IO, PM2 in front of Nginx — and every deploy today is a plain pm2 restart: the process dies, then comes back. For a handful of internal users that’s a two-second blip, not an incident. But it’s worth being precise about the gap between “runs fine” and “zero-downtime,” because closing it is simpler than most guides make it sound, and understanding why I haven’t closed it yet is as useful as the fix itself.
Why This Container Runs in Fork Mode, Not Cluster
PM2’s cluster mode is the usual answer here: run N instances behind Node’s built-in load balancer, replace them one at a time on deploy, and the old instance finishes its in-flight requests while the new one takes over. It’s the right tool — for a service under enough load to justify N processes. My ticketing container has 2 vCPUs and 1 GB of RAM, and normal traffic is a handful of staff and clients refreshing a dashboard. Running instances: 2 in cluster mode would roughly double the memory footprint for zero practical throughput gain. Fork mode — one process, all the RAM budget to itself — is the actual correct choice here, not a shortcut I’m settling for. The point of this post is what changes, and what doesn’t, once you actually need cluster mode.
What Cluster Mode Buys You (When You Need It)
In cluster mode, PM2 runs N copies of your app sharing one port, and pm2 reload — not restart — replaces them one at a time: start the new instance, wait for it to report ready, gracefully retire one old instance, repeat.
// ecosystem.config.js
module.exports = {
apps: [{
name: "api",
script: "./dist/server.js",
instances: "max",
exec_mode: "cluster",
wait_ready: true,
listen_timeout: 10000,
kill_timeout: 8000,
}]
}Two contracts make this actually work, and they’re the same two contracts I’d need to add to my own app before switching:
// tell PM2 you're really ready — after DB/dependencies are confirmed, not on process start
const server = app.listen(PORT, async () => {
await prisma.$connect();
if (process.send) process.send("ready");
});
// drain instead of dying on SIGINT
process.on("SIGINT", () => {
server.close(async () => {
await prisma.$disconnect();
process.exit(0);
});
setTimeout(() => process.exit(1), 7000).unref();
});Skip these and cluster mode still runs — it just silently drops whatever was mid-flight on every deploy, which defeats the entire point while looking like it’s working.
The Socket.IO Complication
This is the part that actually applies to what I run today, cluster mode or not: WebSocket connections don’t respect server.close() the way HTTP requests do. A REST request finishes in milliseconds; a Socket.IO connection can sit open for hours. On restart, clients get disconnected and have to reconnect — which Socket.IO’s client does automatically, but every open ticket’s live presence and “someone is typing” state resets in the process. Right now, with a single process and a hard restart, that’s the actual user-visible cost of a deploy: not downtime exactly, everyone reconnects within a second or two, but a momentary flicker of “who’s online” going blank. It’s a fair trade for a two-person deploy that happens a few times a month; it’s the first thing I’d fix if this app grew past internal use.
If I do move to multiple instances later, broadcasts stop reaching every client for free — each process only knows about its own sockets — and fixing that means a shared adapter (Redis, typically) so an event emitted on one worker reaches sockets connected to another. That’s a big enough topic to earn its own post rather than a paragraph here.
Nginx: What I Do Have Today
Even with a single backend process, Nginx in front does real work — TLS termination and a couple of directives that matter for WebSocket traffic specifically:
location /socket.io/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # long-lived sockets need a long read timeout
}The default Nginx proxy timeout (60s) will silently kill a healthy idle WebSocket connection. This one line cost me an evening of “why do clients randomly disconnect every minute” before I found it — the single most common Socket.IO-behind-Nginx mistake, and the one piece of this post that’s earned, not theoretical.
What I’d Add Before Calling This “Done”
No part of my deploy script currently verifies the new process actually works before declaring success — it’s pm2 restart then hope. The cheap fix, whether or not cluster mode ever happens:
#!/bin/bash
set -euo pipefail
git pull --ff-only
pnpm install
npx prisma db push
pnpm build
pm2 restart tickets
sleep 3
curl -fsS localhost:3000/ > /dev/null
&& echo "deploy OK"
|| { echo "deploy looks broken — check pm2 logs"; exit 1; }Not a real health check — just confirming the process answers at all — but it turns “deployed and silently broken” into “deployed and I find out in the same terminal,” which is most of the value for a few lines of bash.
The Actual Takeaway
Cluster mode, graceful shutdown, health-gated deploys — all real, all worth knowing. But the right first question isn’t “how do I get zero-downtime deploys,” it’s “does this specific service need them yet.” Mine doesn’t, and running fork mode because it’s the correct-sized tool — not because I haven’t gotten around to the fancier setup — is itself the useful thing to say out loud, since most of what gets written about this topic skips straight to the Kubernetes-adjacent answer without asking the question first.