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.
Putting an Actual Number on “It’s Fine”
I’ve been saying the fork-mode restart costs “a two-second blip” without ever measuring it, which is exactly the kind of claim I try not to let stand unchecked. So I built the smallest possible version of both setups — a bare http.createServer stub, nothing else — and fired a request every 20ms for four seconds through a restart, once in fork mode and once in cluster mode, counting how many failed:
# fork mode: pm2 restart mid-traffic
ok=192 fail=8
# cluster mode, 2 instances: pm2 reload mid-traffic
ok=200 fail=0Fork mode dropped 8 requests out of 200 during the restart window — at one request every 20ms, that’s roughly 150-200ms where nothing was listening on the port. Cluster mode’s rolling reload dropped none, which is the entire point of running two instances and replacing them one at a time instead of stopping the one you have. Neither number is surprising once you know how each mode works, but “roughly 150-200ms, measured” is a different thing to know than “a quick blip, probably,” and it’s the number I’ll actually compare against if this app ever gets busy enough that a real request lands in that window instead of nothing.
What this test deliberately doesn’t capture: a bare HTTP stub has no in-flight database queries or WebSocket connections to lose, so it measures the pure “was the port accepting connections” question and nothing about graceful shutdown of real work — that’s what the SIGINT/wait_ready contracts further up this post are for, and I didn’t re-verify those against this same harness. Small, honest scope: this number is about the restart gap, not about whether in-flight requests survive it.
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.
