pm2 reload doesn't give real zero downtime in fork mode - a measured gap of about a second, 11 of 57 requests refused. What actually works: two Next.js instances on two ports, an nginx upstream swap, and a curl loop proving it during the switch.
If you've followed this series end to end, your app runs under PM2 in fork mode on 127.0.0.1:3000, nginx sits in front, and ufw is locked down. One practical question is left: how do you ship new code without a single visitor seeing a dropped connection. A naive `pm2 restart` stops the process and starts it again, and there's a real gap between "stopped" and "listening again." `pm2 reload` sounds like it was built for exactly this, but in fork mode with one process it doesn't keep that promise. Here's a deploy pattern that actually gets you zero downtime: two PM2 processes on two ports, and an nginx upstream switch via reload, plus an honest look at when PM2 cluster mode is the better call instead.
TL;DR. In fork mode with a single process, `pm2 reload` doesn't deliver real zero downtime: the series opener measured roughly a one-second gap on this exact stack, 11 of 57 requests coming back `connection refused`. Two honest paths exist: (1) blue-green, running two Next.js processes on two ports, building the new release in the idle one, checking it locally, then flipping nginx with `nginx -t && systemctl reload nginx`; (2) PM2 cluster mode with `instances: 2` or higher, which genuinely works with `next start` when it's launched directly via its binary path, but keeps a second copy of the process in memory permanently and needs its own answer for Next.js's ISR cache, which isn't shared across processes by default. For the small VPS this series runs on, blue-green wins: the extra memory only gets spent for a few seconds during the actual deploy.
`pm2 restart web` does exactly what it says: stops the process, waits for it to exit, starts it fresh. Between the stop and the moment the new process is actually listening on the port, the port is free, and any request landing in that gap gets `connection refused`.
`pm2 reload` is built for something different. PM2's own docs frame it as the zero-downtime alternative to restart. The mechanism assumes cluster mode: several workers of the same app share one port through Node's built-in `cluster` module, and PM2 brings new workers up one at a time while the old ones keep answering, so at least one is always alive. This series deliberately runs PM2 in fork mode with `instances: 1` (a call made in the series opener: on a 2 GB VPS, a permanent second copy of the process isn't worth it, and the build already peaks around 1.2 GB), and with a single process there's nothing for `reload` to hand off to. There's no second worker, so PM2 ends up going through the same stop-then-start cycle `restart` does. Confirmed live in the series opener: `pm2 reload` produced roughly a one-second gap, 11 of 57 consecutive requests coming back `connection refused`. That's not a PM2 bug, it's a consequence of there being nothing to switch to.
Blue-green is a pattern where two identical copies of the app run side by side, and traffic gets switched from one to the other in a single move instead of a new version landing on top of the old one. Both approaches below share the same goal (at least one live process answering at any given moment) at a different cost:
Blue-green: two ports + nginx | PM2 cluster mode | |
|---|---|---|
Steady-state RAM | Same as one process, ~120-220 MB (measured in the memory-leak article) | Multiplied by instance count, permanently, not just during a deploy |
RAM peak during deploy | Brief: build peak (~1.2 GB) plus the old process, then a few seconds with both running | Unchanged by a deploy, but the baseline stays higher all the time |
What switches traffic | nginx: `proxy_pass` to the new port via reload | PM2 inside a single port, via Node's `cluster` module |
Next.js ISR cache across processes | Not an issue: only one process serves traffic at a time | Per-process by default; needs a shared `cacheHandler` for consistency |
When to pick it | Small VPS, one server, no RAM to spare | You have RAM headroom and/or already run Redis for something else |
For the box this series runs on, a cheap 2 GB VPS with no external cache, blue-green gets the same outcome without a permanent memory bill or a new class of bug from a diverging ISR cache.
Instead of the single `/var/www/app` directory from the deploy article, you need two independent ones: the old one has to keep answering while the new one builds.
sudo mkdir -p /var/www/app-a /var/www/app-b
sudo chown $USER:$USER /var/www/app-a /var/www/app-b
cd /var/www/app-a && git clone https://github.com/your/repo.git .
cd /var/www/app-b && git clone https://github.com/your/repo.git .
Put the same `.env.production` in both directories. Then build both, before the first start: `next start` needs an existing `.next` build to run against, and without it the command below just fails to come up.
cd /var/www/app-a && npm ci && npm run build
cd /var/www/app-b && npm ci && npm run build
If this box already runs a single `web` process on port 3000 from the series opener, delete it first, or it and the new `web-a` will fight over the same port:
pm2 delete web
One `ecosystem.config.js` describes both processes: both fork mode, both launched directly via the Next binary, no npm wrapper (covered in the series opener). Keep the file outside the release directories, same as before, e.g. right at `/var/www/ecosystem.config.js`:
module.exports = {
apps: [
{
name: 'web-a',
script: '/var/www/app-a/node_modules/next/dist/bin/next',
args: 'start -p 3000 -H 127.0.0.1',
cwd: '/var/www/app-a',
interpreter: 'node',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '450M',
env: { NODE_ENV: 'production', PORT: 3000 }
},
{
name: 'web-b',
script: '/var/www/app-b/node_modules/next/dist/bin/next',
args: 'start -p 3001 -H 127.0.0.1',
cwd: '/var/www/app-b',
interpreter: 'node',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '450M',
env: { NODE_ENV: 'production', PORT: 3001 }
}
]
}
-p 3000 / -p 3001: different ports for the two copies, or the second process fails to bind.-H 127.0.0.1: both processes only listen on loopback, the same "second layer" already covered in the nginx/ufw article.Start both processes once, then stop `web-b` right away: at steady state only one runs, the other just sits registered in PM2:
cd /var/www
pm2 start ecosystem.config.js
pm2 stop web-b
pm2 save
In the site config from the nginx/ufw article, swap the `proxy_pass` line for an `include` pointing at a separate snippet file. You need two such snippets, not one: the proxy to the app itself, and separately the `alias` serving Next.js's static assets, which the opener already points at one specific release directory (`app`), not `app-a`/`app-b`. Flip only `proxy_pass` and forget this second block, and the site keeps serving fresh HTML while its JS and CSS 404: the hashed files live in a different directory now, and the alias is still pointed at the old one:
sudo mkdir -p /etc/nginx/snippets
echo 'proxy_pass http://127.0.0.1:3000;' | sudo tee /etc/nginx/snippets/upstream-a.conf
echo 'proxy_pass http://127.0.0.1:3001;' | sudo tee /etc/nginx/snippets/upstream-b.conf
sudo ln -sfn /etc/nginx/snippets/upstream-a.conf /etc/nginx/snippets/active-upstream.conf
echo 'alias /var/www/app-a/.next/static/;' | sudo tee /etc/nginx/snippets/static-a.conf
echo 'alias /var/www/app-b/.next/static/;' | sudo tee /etc/nginx/snippets/static-b.conf
sudo ln -sfn /etc/nginx/snippets/static-a.conf /etc/nginx/snippets/active-static.conf
In the site config, point both `location /` and `location /_next/static/` at their own `include`d snippet pair. Keep every header in `location /` exactly as it was in the nginx/ufw article - don't trim it, each one earns its place there for a reason already covered:
location /_next/static/ {
include /etc/nginx/snippets/active-static.conf;
}
location / {
include /etc/nginx/snippets/active-upstream.conf;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 60s;
}
sudo nginx -t && sudo systemctl reload nginx
X-Forwarded-Proto https is a constant here, not $scheme, for the same reason as in the nginx/ufw article: behind Cloudflare Tunnel, the local hop from cloudflared to nginx is always plain http, and $scheme here would silently substitute "http" for the visitor's real protocol. If you're not behind a tunnel and nginx terminates TLS itself, use $scheme as in the base nginx article instead.
`active-upstream.conf` and `active-static.conf` are symlinks, not files in their own right. Deploys flip both symlinks' targets together, not the `location` blocks themselves. After this one-time setup, traffic and static assets both go to `web-a`/`app-a`, and `web-b`/`app-b` sits ready but not running.
Start by checking which one is currently live:
readlink -f /etc/nginx/snippets/active-upstream.conf
Output ending in `upstream-a.conf` means `web-a` on 3000 is live, and the deploy target is `app-b`. The example below assumes that; if it's the other way, swap `a` and `b`.
1. Build the new release in the idle directory. Same build order as the deploy article, with the bonus that `app-a` keeps answering real traffic the whole time, since nothing touches its files.
cd /var/www/app-b
git pull
npm ci
npm run build
2. Bring the idle process up with the new code. `web-b` is currently `stopped`, and this same command brings it back. Point it at the config file explicitly, with an absolute path: PM2 needs to re-read the whole file, and this should work from wherever you just ran the build, not just from `/var/www`:
pm2 restart /var/www/ecosystem.config.js --only web-b --update-env
3. Check the new version on its own port, before a single visitor sees it:
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3001/
Expect `200`. A different code, or no response at all, means stop here: nginx is still pointed at `web-a`, and nothing visitor-facing changed. Check `pm2 logs web-b`, fix it, repeat step 2.
4. Flip nginx, only now that the new version has confirmed it answers on its own. Flip both symlinks together, the upstream and the static one, or you'll ship working HTML with 404s on JS/CSS:
sudo ln -sfn /etc/nginx/snippets/upstream-b.conf /etc/nginx/snippets/active-upstream.conf
sudo ln -sfn /etc/nginx/snippets/static-b.conf /etc/nginx/snippets/active-static.conf
sudo nginx -t && sudo systemctl reload nginx
`ln -sfn` swaps the symlink's target in one atomic operation. `reload` spins up new nginx workers with the new `include` already in place, while the old workers finish off whatever connections were open before the switch and exit on their own, which is exactly what keeps this gap-free (the full `nginx -t`/`reload` breakdown lives in the nginx/ufw article).
5. Wait a bit, stop the old process, and save the new state in PM2. Right after `reload`, some requests are still being served by old nginx workers still talking to the old port. Kill `web-a` too soon and those tail requests get cut off. A couple of seconds is usually enough for a typical site; for genuinely long-running requests (streaming, heavy page generation), stretch the pause to match your `proxy_read_timeout`. One last step that's easy to forget: skip `pm2 save` and the process list PM2 restores after a reboot stays the old one - `web-a` comes back online, `web-b` doesn't, while nginx is already pointed at port 3001. The site survives the reboot itself with a wall of 502s, until someone notices and starts the process by hand.
sleep 5
pm2 stop web-a
pm2 save
Deploy done: `web-b` on 3001 is serving production, `web-a` is stopped and waiting for the next round, and `pm2 save` locked in exactly this state in case of a reboot. Next time, the roles reverse.
The honest way to confirm zero downtime isn't a feeling, it's polling the site continuously through the whole switch. Run this in a separate terminal on your own machine, not the VPS, starting a minute before the deploy:
while true; do curl -o /dev/null -s -w "%{http_code} %{time_total}\n" https://your-domain/; sleep 0.2; done
-w "%{http_code} %{time_total}\n" prints the response code and the request time instead of the page body itself.sleep 0.2 polls roughly five times a second, often enough to catch a sub-second gap.What you should see across the whole deploy: an unbroken stream of lines like `200 0.045`, no `000` (meaning the connection never got established), no `502`/`504`, no spikes in `time_total`. That's what real zero downtime looks like in a log, not a small gap nobody noticed, an actual absence of one. For comparison, run the same loop through nginx against a plain `pm2 restart` on a single process, and you'll see a handful of `502` lines, not `000` - nginx on port 80 is still right there answering honestly, it just has nothing to proxy to while the app's port sits empty. You'd only see `000` polling the app's port directly, bypassing nginx entirely - a working deploy through nginx should show neither.
If keeping a second copy of the process in memory permanently isn't a problem, a VPS with headroom, or you already run Redis for something else, cluster mode gets you zero downtime without two ports and a manual nginx switch. It genuinely works with `next start` launched directly via its binary path, exactly how this series' `ecosystem.config.js` has done it from the start: PM2 uses Node's built-in `cluster` module underneath, which transparently shares a port across processes with no changes to the app itself. The config differs by two lines:
exec_mode: 'cluster',
instances: 2,
From there, `pm2 reload ecosystem.config.js --only web` genuinely gives you zero downtime, bringing up a new worker, waiting for it to answer, then killing the old one.
The cost is two things. Instead of one process at ~120-220 MB, at least two run all the time, roughly doubling the RAM baseline permanently rather than just during a deploy. And Next.js's ISR cache (incremental static regeneration: cached pages that get refreshed on a schedule or on demand instead of re-rendering on every visit) is stored in memory separately per process by default; Next.js's own docs say plainly that with multiple instances it isn't shared, and different workers can serve different content for the same page for a while, until you wire up a shared `cacheHandler` (Redis, for instance). For a site with little or no ISR, that's not a real concern. For one that revalidates pages often, it's a new class of bug unrelated to the deploy itself.
Only in cluster mode with more than one instance, where there's someone to hand off to. In fork mode with a single process, `reload` effectively goes through the same stop-then-start cycle as `restart`: the test on this series' stack measured roughly a one-second gap, 11 of 57 requests coming back `connection refused`.
Run two Next.js processes on two ports under PM2, build the new release in whichever directory isn't currently serving traffic, verify it locally on its own port, then switch nginx's `proxy_pass` to the new port with `nginx -t && systemctl reload nginx`. Stop the old process a few seconds after the switch, not immediately.
Yes, as long as `script` points directly at the Next binary rather than `npm`/`yarn` as a wrapper. The cost is a permanently doubled memory footprint and an in-memory ISR cache that isn't shared across processes by default.
Run a continuous poll of the site every 0.2 seconds, printing the response code and request time, starting before the deploy. If the log shows no `000`, `502`, or `504` line for the entire switch, there was no downtime.
Move `proxy_pass` into a separate snippet file that the main config references via `include`, then swap the symlink to point at the snippet you want active; `ln -sfn` changes the target in one atomic operation, and nginx's old workers finish serving whatever connections were already open after the reload.
pm2 restart and pm2 reload in fork mode with a single process don't give real zero downtime: the measured gap was around one second.This closes out the series on deploying Next.js to a VPS: from the first PM2-and-nginx launch, through the perimeter with ufw, to shipping new versions without dropping a single connection. The full path in order: deploying a service on a server, deploying a Next.js site on a VPS, diagnosing the memory leak, a healthcheck that actually restarts, nginx and ufw in front of your app, and this one on zero-downtime deploys. If a process that hangs instead of crashing isn't covered by an external check yet, start with the healthcheck article: without it, blue-green ships the new version fine, but won't notice if it quietly hangs an hour later.