Zero-downtime Next.js deploys with PM2 in fork mode and nginx

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.

Why pm2 restart and pm2 reload don't give Next.js a zero-downtime 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.

Two honest paths, and why this architecture picked blue-green

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.

Blue-green: one-time setup

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.
  • Everything else (`min_uptime`, `exp_backoff_restart_delay`, `kill_timeout`, `autorestart`, `watch`) matches the series opener; not repeated here.

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.

The actual deploy: five steps, repeated every time

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.

Proving nobody noticed

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.

Alternative: PM2 cluster mode, if you have the RAM to spare

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.

FAQ

Does pm2 reload give real zero downtime?

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`.

How do you deploy Next.js to a VPS with zero downtime?

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.

Does PM2 cluster mode work with next start?

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.

How do you verify a deploy had zero downtime?

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.

How do you switch nginx to a new app port without dropping connections?

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.

Takeaways

  • 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.
  • The working answer for this architecture is blue-green: two PM2 processes on two ports, always building in the idle directory, checking the new version on its own port before switching.
  • Switching traffic means flipping a symlink to a snippet file holding `proxy_pass`, always with `nginx -t` before `systemctl reload nginx`.
  • Stop the old process a few seconds after reload, not immediately, or you'll cut off tail requests old nginx workers are still finishing.
  • Proving there was no downtime means a continuous curl poll every 0.2 seconds running through the whole switch, from a separate terminal.
  • PM2 cluster mode technically works with `next start` and also gives zero downtime, but permanently doubles memory and needs a shared cache for ISR. That's why this series' small VPS picked blue-green instead.

What's next

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.

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU