(card blurb):
You have a Next.js app and you want it live on your domain around the clock, not just while npm run dev is open on a laptop. The budget is a VPS at $3-5 a month, self-managed. This is the full path to deploy a Next.js site on a VPS: install Node.js, build the project, run it under PM2, optionally wrap it in nginx, and expose it through a Cloudflare Tunnel with no inbound port and no manual certificate work. It is written from a real deploy on Ubuntu 24.04, 1 vCPU, 2 GB of RAM, kept deliberately small so you can see exactly where it strains.
In short. Install an LTS Node.js from NodeSource, run
npm ciandnpm run build. The build must happen before the first start: on older Next versionsnext startcrashed without it, on Next 16 the server comes up but serves broken pages. Runnext startdirectly through anecosystem.config.jsunder PM2 in fork mode, not vianpm start. The public entry is a Cloudflare Tunnel viaconfig.yml:cloudflaredholds an outbound connection to Cloudflare, so inbound 80/443 stay closed, certbot is not needed, and the origin IP stays hidden. By default the tunnel goes straight to the app; nginx is a separate layer for disk-served static and rate limiting. Autostart ispm2 startuppluspm2 saveafter every change, verified with a realsudo reboot.
A production Next.js app is not a folder of static files (unless you do a full static export, covered below). It is a live Node.js process: next start runs a server that returns HTML and handles server components, API routes, middleware and ISR. That needs three things on top of bare Node.js.
config.yml): cloudflared holds an outbound connection to Cloudflare and opens no ports at all.next start alive, watches memory, sets up autostart. Remove it and after a crash or reboot the site is down until you SSH in by hand.X-Forwarded-* headers, rate-limits. Remove it and the app serves its own static assets and works, but you lose disk-served files, a single place for headers, and rate limiting.Fork: your site is fully static. If
next.config.jssetsoutput: 'export', the app builds into anout/folder and needs no Node at runtime. PM2 drops out: serveout/directly from nginx and point the tunnel at nginx. That is the cheapest option, but with no on-request server rendering, no API routes, no ISR and no middleware. Everything below assumes the normal SSR mode withnext start.
A running Next.js site is light: next-server comes up at around 120 MB RSS (pm2 showed 112 MB in the measurement) and settles at a 200-220 MB plateau under load. The max_memory_restart: '450M' limit from step 4 fires well before memory runs out, so it is a leak guard rather than a normal mechanism. One core comfortably serves several hundred requests per minute for a mid-weight site.
The heavy part is the build. npm run build for even an empty Next 16 app peaks at about 1.2 GB RSS (measured 1,266,740 KB). On a 1 GB plan the build does not complete, even for a hello-world. So 2 GB is the practical minimum, and a real project needs one of three things on top:
NODE_OPTIONS=--max-old-space-size=1536 before npm run build, which caps the V8 heap so the compiler does not balloon;.next folder, so npm run build never runs on the server.Task | 1 GB | 2 GB | 4 GB |
|---|---|---|---|
| fails even for an empty app (~1.2 GB peak) | the minimum; a mid-weight project needs swap or | comfortable |
next-server at start | - | ~120 MB RSS | ~120 MB RSS |
next-server under load | - | ~200-220 MB, well below the 450 MB limit | same |
Verdict | ship a CI-built | working minimum for SSR | heavy ISR or many dependencies |
This guide uses the 2 GB plan plus 2 GB of swap: the build of a mid-weight project passes and runtime has room left.
Baseline hardening first: SSH key login instead of a password, and a firewall. That is a topic of its own, covered in the fresh-VPS hardening guide. The minimum is to allow SSH in the firewall and enable it:
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status
allow OpenSSH is the ufw profile that opens port 22 (or your custom port, if you changed it).Do not stay on root: create a regular user and grant sudo when needed. The app will live in that user's home directory or in /var/www/app.
The Next.js build is memory-hungry (see the section above), so on 2 GB, and certainly on 1 GB, add swap: a disk-backed paging file that covers the peak.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
fallocate -l 2G reserves a 2 GB file; chmod 600 keeps it private./etc/fstab line re-enables swap after a reboot.swapon --show and free -h now list a swap line.Swap on a cheap disk is slow and is not a substitute for RAM. Treat it as a safety net during the build, not as a running mode.
Ubuntu's default repository ships an old Node.js. Take an LTS from NodeSource, the project's official repository. Version 20 or newer works: the test box took setup_24.x (node v24.21.0), and the production project runs Node 20 LTS, which is also fine. Take the current LTS and check node -v afterwards.
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
setup_24.x: substitute the branch you want, setup_20.x, setup_22.x or setup_24.x.-fsSL: quiet, no progress bar, fail on a bad response, follow redirects.sudo -E bash -: the script adds the NodeSource repo and its key; -E keeps your environment variables.What you should see:
node -v
npm -v
The first prints a version number such as v20.x.x or v24.x.x, the second the npm version. If node -v returns command not found, the repo was not added: re-read the output of curl ... | bash.
Get the code onto the server, usually a git clone into /var/www/app (create the directory first and hand it to your user: sudo mkdir -p /var/www/app && sudo chown $USER:$USER /var/www/app).
cd /var/www/app
git clone https://github.com/you/repo.git .
Next, environment variables. Secrets (API keys, connection strings) are not committed to the repo; on the server they live in a .env.production file at the project root. Next.js itself reads it, from its own working directory, which matters for step 4.
nano .env.production
chmod 600 .env.production
Install dependencies strictly from the lockfile, then build:
npm ci
npm run build
npm ci (clean install) installs packages exactly per package-lock.json, wiping node_modules first. It is reproducible and faster than npm install, which can silently bump versions.npm run build runs next build, compiling the app into the .next directory.Why this order. On Next 13-14, running next start without a build failed immediately with Error: Could not find a production build in the '.next' directory. On Next 16 the behaviour is softer and sneakier: the server comes up but serves broken pages and logs errors, so the symptom is less obvious while the outcome is the same. The rule is simple: build before every start, and after every code change.
What you should see at the end of the build: a line about a finished compile and a route table with render-type markers (Static, SSG, ƒ for dynamic). Check that .next/BUILD_ID now exists:
cat .next/BUILD_ID
A quick manual check that the app starts at all (port 3000, then Ctrl+C):
node_modules/next/dist/bin/next start -p 3000 &
sleep 3
curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "responds"
kill %1
curl -fsS <URL>: -f returns a non-zero exit code on an HTTP 4xx/5xx, -sS is quiet but still prints errors. Handy in scripts.responds printed means the server came up and returned a page.PM2 is a process manager for Node: it starts the app, brings it back after a crash, restarts it on a memory limit, keeps logs, and can start all of that on boot. Install it globally:
sudo npm install -g pm2
Describe the start config in an ecosystem.config.js. Keep it outside the release directory (in /var/www/, say) so a deploy does not touch it. Here is a working version from the production project; the key-by-key breakdown is below it:
module.exports = {
apps: [{
name: 'web',
script: '/var/www/app/node_modules/next/dist/bin/next',
args: 'start',
cwd: '/var/www/app',
interpreter: 'node',
exec_mode: 'fork',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '450M',
min_uptime: '30s',
max_restarts: 15,
exp_backoff_restart_delay: 200,
kill_timeout: 8000,
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
}
script: an absolute path to the Next binary (node_modules/next/dist/bin/next), not the .bin/next symlink and not npm. interpreter: 'node' is required, since PM2 does not always infer how to run the file. More on running via npm below; it is the key point.cwd: the process working directory. Required, but not because PM2 reads it. Next.js reads the .env* files from its own working directory (process.cwd()). Without cwd, PM2 starts Next from its own directory, Next never finds .env.production, and the app starts with no variables. If a variable is needed before Next starts (or by the wrapper itself), put it in this config's env: {} block or use env_file.exec_mode: 'fork', instances: 1: a single process, no clustering. PM2's cluster mode spawns process copies, each with its own memory; on 2 GB that only hurts, and Next is usually scaled with several VPS behind a load balancer.autorestart: true: bring the process back after a crash. watch: false: do not watch files and restart on every change (in production that only gets in the way).max_memory_restart: '450M': if the process RSS crosses 450 MB, PM2 restarts it. The next-server working plateau is 200-220 MB, so this is pure leak insurance.min_uptime: '30s' + max_restarts: 15: if the process lives less than 30 seconds, 15 times in a row, PM2 stops retrying. Catches a broken build without an endless loop.exp_backoff_restart_delay: 200: the delay between restarts grows exponentially from 200 ms. It does not hammer the box when the app crashes instantly.kill_timeout: 8000: give next-server 8 seconds to shut down cleanly on SIGTERM before PM2 kills it hard. Fewer dropped requests on a restart.env: variables PM2 always injects. PORT: 3000 is the port the app listens on.Why not
npm start. Ifscriptisnpmandargsisstart, PM2 supervises the npm process: a thin wrapper that does almost nothing and sits at 0% CPU. The realnext-serverruns as its child, and when that child hangs or leaks, PM2 never sees it, because it is watching the wrapper.max_memory_restartthen measures npm's memory, not the app's, and does not fire in time. So run the Next binary directly.
Start it and check the state:
cd /var/www/app
pm2 start /var/www/ecosystem.config.js
pm2 status
pm2 logs web --lines 30
What you should see: in pm2 status, the web app is online, the restart column is 0, memory is around 110-120 MB. Final check:
curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "app is live"
If restart climbs while you watch, read pm2 logs web: it is usually a missing environment variable (cwd unset or .env.production missing) or a bug in the code. The memory leaks that make max_memory_restart fire too often get their own article in this series (in progress).
If you use
output: 'standalone'. In that mode Next writes a self-contained server to.next/standalone/server.js, and that is what you run:script: '/var/www/app/.next/standalone/server.js'. It expectspublic/and.next/static/next to it; Next does not copy them for you, so copy them at build time. The upside: fewer dependencies on the server and lower memory use.
You can skip this layer. By default (see step 6) the tunnel talks to the app directly and the site works. Add nginx when you need to serve /_next/static/ straight from disk, set shared headers, rate-limit, or route several backends.
sudo apt-get install -y nginx
Behind a Cloudflare Tunnel, proxy_pass sees 127.0.0.1, and without configuration the app gets 127.0.0.1 as the visitor IP. To restore the real address, tell nginx to trust the local connection and take the IP from the Cloudflare header. In /etc/nginx/conf.d/realip.conf:
set_real_ip_from 127.0.0.1;
real_ip_header CF-Connecting-IP;
set_real_ip_from 127.0.0.1: trust this source to override the IP (cloudflared connects from localhost).real_ip_header CF-Connecting-IP: Cloudflare puts the visitor's original IP in this header, cloudflared passes it through. After this, $remote_addr and X-Real-IP become the real address.The main config at /etc/nginx/sites-available/app (edit under sudo):
server {
listen 127.0.0.1:80;
server_name _;
server_tokens off;
# Hashed Next.js assets: straight from disk, cache for a year
location /_next/static/ {
alias /var/www/app/.next/static/;
access_log off;
expires 365d;
add_header Cache-Control "public, immutable";
}
# Everything else: to the app
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
listen 127.0.0.1:80: nginx listens on localhost only. cloudflared connects locally, so nothing is exposed.server_tokens off: drops the nginx version from headers and error pages.location /_next/static/ via alias (not root), both paths with a trailing slash. File names contain a hash, so expires 365d and immutable are safe: a new build means new names. Verified: returns 200 with Cache-Control: public, immutable, max-age=31536000.proxy_http_version 1.1: without it keep-alive to the app does not work and every request opens a new connection.X-Forwarded-Proto https: a constant, not $scheme. Everything that reaches nginx came through the tunnel from Cloudflare, where TLS is already terminated, so the scheme is always https. (No separate map is needed for a single upstream. If you also serve the site over plain http directly, go back to $scheme.)X-Forwarded-Host $host: the app sees the original domain even if it builds links from the host value.proxy_read_timeout 60s: how long to wait for the app's response. 60 seconds is a sane default; for ISR, long regeneration or streaming, tune it per app. If the app uses WebSockets, add the Upgrade/Connection headers; for plain SSR you do not need them.Enable the config, validate, reload:
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
After nginx -t, expect syntax is ok and test is successful. Check through nginx (put your domain in Host):
curl -fsS -o /dev/null -w '%{http_code}\n' -H 'Host: example.com' http://127.0.0.1/
Expect 200 (or whatever code your site root returns). A 403 on /_next/static/ usually means the nginx worker cannot traverse the directories down to .next/static: check permissions on /var/www/app and its subfolders.
cloudflared is a small daemon that opens an outbound connection to the Cloudflare edge and holds it. Visitors reach Cloudflare, which forwards requests down that connection to your server. The result: no inbound port, TLS terminates on Cloudflare's side (no certificate needed), and the origin IP is not in DNS.
Prerequisite: the domain is served by Cloudflare (its nameservers are set). Install cloudflared from the Cloudflare repository:
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
Check the version: cloudflared --version (2026.8.3 on the test box).
1. Authenticate and create the tunnel. The first command opens a login link for your Cloudflare account and stores a certificate in ~/.cloudflared/. The second creates a named tunnel and prints its UUID; the tunnel credentials go to ~/.cloudflared/<UUID>.json.
cloudflared tunnel login
cloudflared tunnel create web
2. Write /etc/cloudflared/config.yml. Put in your UUID and domain:
tunnel: <UUID>
credentials-file: /root/.cloudflared/<UUID>.json
ingress:
- hostname: example.com
service: http://localhost:3000
- service: http_status:404
ingress is a top-to-bottom list of rules: which host goes to which local service. The last rule with no hostname is required: it is the default response.service: http://localhost:3000: the tunnel goes straight to the app. If you want nginx in between (step 5), use http://localhost:80.3. Create the DNS record for each hostname. The command adds a proxied (orange-cloud) CNAME to <UUID>.cfargotunnel.com:
cloudflared tunnel route dns web example.com
4. Write the systemd unit by hand. In /etc/systemd/system/cloudflared.service:
[Unit]
Description=cloudflared tunnel
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
Restart=on-failure
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
--no-autoupdate: do not self-update; keep the version under apt control.Restart=on-failure + RestartSec=5: bring the daemon back after a failure with a 5 second pause.After/Wants=network-online.target: start once the network is up.sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared
What you should see:
cloudflared tunnel list
systemctl status cloudflared --no-pager
The list shows the tunnel with a recent connection; the service status is active (running). From your laptop:
curl -sS -I https://example.com
Expect a successful response (the code depends on the site) and a server: cloudflare header. The site opens over https with a valid certificate you never issued.
service: http://localhost:3000). No nginx at all. The app serves its own static assets; you lose only disk-served files and shared headers, and the site still works.service: http://localhost:80, nginx on 127.0.0.1:80) which then proxies the app. Use this when rate limiting, disk-served /_next/static/, several backends, or shared headers in one place matter.Classic: 80/443 + certbot | Cloudflare Tunnel | |
|---|---|---|
Inbound ports | 80 and 443 open | none except SSH |
TLS certificate | you issue and renew it (certbot) | TLS terminates on Cloudflare, renewal is not your problem |
Origin IP | visible to anyone resolving the domain | hidden behind Cloudflare |
Dependencies in the request path | your server and a CA | plus Cloudflare |
Domain requirements | any DNS works | domain must be on Cloudflare |
Extra network hop | none | yes, via the Cloudflare edge |
Pick when | you need raw TCP, no third party in the path, not on Cloudflare | a cheap VPS where you want web ports closed and the origin hidden |
For a small self-managed VPS the tunnel is usually the easier call: less attack surface and nothing to renew. If you need full control over the request path or a non-HTTP protocol, the classic certbot setup is more honest. For a reverse proxy with automatic HTTPS and no separate certbot, see the Caddy write-up.
Default recommendation: with a tunnel, do not open 80 or 443 at all. Only SSH is exposed. Check sudo ufw status: the list should hold only OpenSSH. The cloudflared outbound connection still works: ufw filters inbound by default, not outbound.
If you did open port 80 for direct-IP checks, restrict it to the Cloudflare ranges, both IPv4 and IPv6:
for ip in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
sudo ufw allow from "$ip" to any port 80 proto tcp
done
sudo ufw deny 80
ufw deny 80 at the end closes the port to everyone else.Rule: do not build over a running .next. During next build the directory is incomplete for a while, and if the app restarts right then it comes up on a broken build. So build aside and switch atomically, in one operation, with no "build is missing" state in between.
# persistent things live outside releases
sudo mkdir -p /var/www/releases /var/www/shared
# put .env.production into /var/www/shared/ once
REL=/var/www/releases/$(date +%Y%m%d-%H%M)
git clone --depth 1 https://github.com/you/repo.git "$REL"
ln -s /var/www/shared/.env.production "$REL/.env.production"
cd "$REL"
npm ci
npm run build
# check the build on a free port without touching production
node_modules/next/dist/bin/next start -p 3001 &
sleep 3
curl -fsS http://127.0.0.1:3001/ >/dev/null && echo "new build responds"
kill %1
# switch: the symlink flip is instant
ln -sfn "$REL" /var/www/app
pm2 restart web --update-env
curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "production updated"
ln -sfn replaces the symlink in one operation: there is no moment when /var/www/app does not exist.ecosystem.config.js sits in /var/www/, its cwd is /var/www/app, that is, the path through the symlink. After the switch, pm2 restart re-execs the process with the new code.Rollback is pointing the symlink back at the previous release:
ln -sfn /var/www/releases/<previous> /var/www/app
pm2 restart web
Keep the last 2-3 releases and delete the rest: ls -1dt /var/www/releases/* | tail -n +4 | xargs rm -rf.
If you do not want release directories, and code plus node_modules change rarely, swap only .next. Build in a separate clone at /var/www/app-build, then:
cd /var/www/app
cp -r /var/www/app-build/.next .next.new
mv .next .next.prev && mv .next.new .next
pm2 restart web
mv within one filesystem is atomic: there is never a moment with an empty .next. Rollback: mv .next .next.bad && mv .next.prev .next && pm2 restart web.
pm2 reload promises a restart with no drop, but in fork mode with a single process it does not deliver: in the measurement, during reload, 11 of 57 requests returned connection refused and the gap lasted about a second. True zero downtime needs cluster mode with instances: 2 or more behind nginx, or a second app instance behind a load balancer. If a short gap on deploy is acceptable, stay on fork and pm2 restart: it is simpler and more predictable.
nginx and cloudflared run as systemd services and start on boot by themselves. Check: systemctl is-enabled nginx cloudflared, both answer enabled.
PM2 needs its own setup, two steps:
pm2 startup
# it prints a ready command, for example:
# sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u deploy --hp /home/deploy
# copy and run it
pm2 save
pm2 startup installs a pm2-<user>.service systemd unit (as root that is pm2-root.service, command pm2 startup systemd -u root --hp /root). The unit calls pm2 resurrect on boot.pm2 save writes the current process list to ~/.pm2/dump.pm2. resurrect restores exactly what was in the last pm2 save. Hence the rule: run pm2 save after every change to ecosystem.config.js or the process set, or a reboot restores a stale state, or nothing.Verify with a reboot, not on paper:
sudo reboot
# reconnect after a minute
pm2 status
curl -sS -I https://example.com
pm2 status shows the app online with a small uptime and restart 0, and the public URL opens. On the test box this matched exactly. The finer points (PATH in the unit, service ordering, delays before the network is up) get their own article in this series (in progress).
Symptom | Cause | What to do |
|---|---|---|
Right after start: 500s or blank pages, logs complaining about |
|
|
The app cannot see variables from |
| set |
The site does not come back after a reboot |
| do both, |
| the process on :3000 is not responding, usually crashed during the build for lack of memory or from a bug (the 200-220 MB runtime plateau does not reach the 450 MB limit) |
|
| nothing is listening on the local port from |
|
| the server responds too slowly: CPU or memory pressure, or the timeout is too short |
|
| a wrong | match |
The | a broken build or a missing required variable; |
|
An endless http -> https redirect | the app treats the connection as insecure | if the tunnel goes through nginx, send |
Install an LTS Node.js, copy the code, run npm ci and npm run build. Run next start under a process manager (PM2 or systemd) so the app survives crashes and reboots. Expose it through a Cloudflare Tunnel via config.yml with no open ports; add nginx as a separate layer if you need disk-served static assets, rate limiting or several backends.
Both keep the process alive and start it on boot. PM2 is faster to set up and ships max_memory_restart, exponential restart backoff, autostart and readable logs, which helps when an app leaks memory. systemd is the built-in Linux mechanism with no global npm package, chosen when you want fewer dependencies. You do not need both: PM2 installs a single unit via pm2 startup.
One server block: location / with proxy_pass http://127.0.0.1:3000 and the Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers; a separate location /_next/static/ with an alias to .next/static and a one-year cache. Behind Cloudflare add real_ip_header CF-Connecting-IP, or the app sees 127.0.0.1 instead of the visitor. Validate with nginx -t and reload nginx.
Yes, and that is the default: the ingress in config.yml points straight at http://localhost:3000. Add nginx as a separate layer when you need disk-served static assets, rate limiting, shared headers or several backends. Without nginx the app serves its own static assets and the site works; you lose the benefits of that layer, not the site.
No. Building even an empty Next 16 app peaks at around 1.2 GB RSS, so 1 GB is not enough for a hello-world. 2 GB is the practical minimum; a real project needs swap, NODE_OPTIONS=--max-old-space-size=1536, or a CI build that ships a ready .next. A running site is lighter: about 120 MB at start, 200-220 MB under load.
The process runs under PM2 but the state was never saved. On boot the pm2-<user>.service unit calls pm2 resurrect and restores whatever was in the last pm2 save. Run the printed pm2 startup command with sudo, run pm2 save after every config change, and verify with a real sudo reboot. nginx and cloudflared come up by themselves if their units are enabled.
No. TLS terminates on Cloudflare, and traffic from cloudflared to the app (or to nginx) goes over localhost. You never install or renew a certificate on the server. If you later open direct access on 443 bypassing the tunnel, then you need certbot.
next start and after every code change. On Next 16 the server comes up without a build but serves broken pages.next start directly, by absolute path to node_modules/next/dist/bin/next, with interpreter: 'node'. Via npm start PM2 watches the wrapper, not next-server, and max_memory_restart measures the wrong thing.cwd is required: Next reads .env files from its own working directory, not PM2.--max-old-space-size or a CI build.max_memory_restart 450M is a leak guard.:3000; nginx is an optional layer for disk-served static, rate limiting and shared headers.mv the .next directory within one filesystem). Keep the previous release for rollback.pm2 reload/restart in fork mode with one process is a ~1 second gap. Zero downtime needs cluster mode with instances of 2 or more behind nginx.pm2 startup + pm2 save after every change, verified with a real reboot.