Deploy a Node.js app with zero downtime: Node.js app deployment with PM2, nginx and atomic releases

Last updated August 5, 2026
# the short answer

To deploy a Node.js app with zero downtime, build each release in its own directory, then use pm2 reload in cluster mode instead of pm2 restart. Reload starts replacement workers and only kills the old ones once the new workers signal they are listening, so no request is dropped. Two things make it actually work: wait_ready with process.send('ready') so PM2 waits for your app to finish booting, and a SIGINT handler that calls server.close() so in flight requests finish before the worker exits.

Skip the manual setup. DeployManage provisions your server and ships zero-downtime deploys on any cloud.

$ get started free

The deploy that most Node.js projects start with looks like this: SSH in, git pull, npm install, pm2 restart app. It works, in the sense that the new code ends up running. It also drops every request that was in flight, and for the few seconds between npm install rewriting node_modules and the process coming back up, your users get connection errors.

Fixing that is not one change. It is three, and they stack: an atomic release layout so the filesystem is never half updated, PM2 reload so processes overlap instead of gapping, and a graceful shutdown handler so the workers going away finish what they started. Skip any one of them and you still drop requests, just less often, which is worse because it is harder to notice.

Why pm2 restart drops requests and pm2 reload does not

pm2 restart does exactly what the name says. It kills your processes, then starts them again. Between those two events there is nothing listening on the port, so every request that arrives gets refused. On a Node app that takes two seconds to boot, that is a two second outage on every single deploy.

pm2 reload is the zero downtime version, and the PM2 documentation is explicit that it "achieves a 0-second-downtime reload". It works by starting replacement workers first, waiting for them to come up, and only then shutting down the old ones. At every moment there is at least one worker able to serve a request.

The catch is that reload only works in cluster mode. Cluster mode runs multiple instances of your app behind Node's built in cluster module, which the docs describe as scaling "networked Node.js applications (http(s)/tcp/udp server) across all CPUs available, without any code modifications". You start it with an instance count:

pm2 start app.js -i max --name api

-i max uses every available CPU. On a small VPS you may want a fixed number instead, -i 2 for example, leaving headroom for nginx, your database and the deploy itself. If you start your app in fork mode (the default, no -i flag), pm2 reload silently behaves like a restart and you get the downtime back without any warning.

Make PM2 wait until your app is actually ready

By default PM2 considers a worker online as soon as the process has spawned. That is almost never true for a real application. Your app still has to connect to Postgres, open a Redis connection, maybe load a config from somewhere. If PM2 kills the old workers at spawn time, there is a window where the new workers are alive but not yet able to answer, and requests fail.

The fix is the wait_ready option. With it set, PM2 waits for your app to send an explicit ready signal before treating the worker as online:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'api',
    script: 'server.js',
    instances: 'max',
    exec_mode: 'cluster',
    wait_ready: true,
    listen_timeout: 10000,
    kill_timeout: 10000,
  }]
}

And in your application, after everything that has to be up is up:

const server = app.listen(PORT, async () => {
  await db.connect()
  await cache.connect()
  if (process.send) process.send('ready')
})

Two numbers matter here. listen_timeout is how long PM2 waits for that ready signal before giving up and proceeding anyway; the PM2 docs state the default is 3000ms, which is short for an app that connects to a few services on boot. Raise it to comfortably exceed your real boot time. kill_timeout is how long PM2 gives a worker to exit on its own before forcing it, and the default is short too: PM2 sends SIGINT first, then delivers SIGKILL if the process has not exited within 1.6 seconds. If your longest request takes eight seconds, a 1.6 second grace period cuts it off.

Handle shutdown gracefully, or the grace period does nothing

Raising kill_timeout only helps if your app uses the time. PM2 sends SIGINT to a worker it wants gone. Node's default behavior on SIGINT is to exit immediately, killing every in flight request. You have to intercept it:

let shuttingDown = false

function shutdown() {
  if (shuttingDown) return
  shuttingDown = true

  // stop accepting new connections, let existing ones finish
  server.close(async () => {
    await db.end()
    await cache.quit()
    process.exit(0)
  })

  // backstop: do not hang forever on a stuck connection
  setTimeout(() => process.exit(1), 9000).unref()
}

process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)

The shuttingDown flag matters because both signals can arrive, and running the teardown twice throws. The setTimeout backstop matters because server.close() waits for existing connections to end, and with HTTP keep-alive an idle browser connection may simply sit there. That is the trap that catches most people: server.close() stops new connections but will happily wait forever on a client that opened a persistent connection and went quiet. Give it a deadline that is shorter than kill_timeout, so your app exits on its own terms rather than being SIGKILLed mid write.

Build each release in its own directory

PM2 reload solves the process gap. It does nothing about the filesystem. If your deploy runs git pull instead of deploying from Git properly in the directory your app is currently running from, then for the duration of that install node_modules is in an inconsistent state, and any worker that lazily requires a module during that window will throw.

The standard layout, the same one Capistrano popularised and the one every serious deploy tool uses, keeps releases separate:

/var/www/api/
  releases/
    20260805T142230/
    20260805T101544/
    20260804T173012/
  shared/
    .env
    uploads/
    logs/
  current -> releases/20260805T142230

Each deploy clones into a new timestamped directory under releases/, symlinks the shared paths that must survive a deploy, runs npm ci and your build, and only then moves the current symlink. Moving a symlink is a single atomic filesystem operation. There is no moment where current points at something half built.

Use npm ci rather than npm install in a deploy. It installs strictly from the lockfile and starts from an empty node_modules, which is what you want for a reproducible release. npm install can quietly resolve a different version than the one you tested.

One detail that bites people: PM2 resolves the script path when the process starts. If you launched it against /var/www/api/current/server.js, the worker holds the resolved path from when it started, so a reload after the symlink moves is what actually picks up the new code. Reload after the switch, never before.

Give nginx somewhere to send traffic during the swap

With cluster mode and reload, PM2 keeps a worker listening at all times, so nginx usually needs no special configuration. But if you run separate PM2 apps on different ports rather than one clustered app, or you want belt and braces during the reload window, an upstream pool with failover costs nothing:

upstream api {
  server 127.0.0.1:3000;
  server 127.0.0.1:3001;
}

server {
  location / {
    proxy_pass http://api;
    proxy_next_upstream error timeout http_502 http_503;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
  }
}

proxy_next_upstream tells nginx to retry the next server in the pool when it gets an error, a timeout or a 502 from the first one, so a worker that goes away mid reload costs a few milliseconds rather than an error page. Setting proxy_http_version 1.1 with an empty Connection header enables keep-alive to the upstream, which is worth having anyway.

Verify the release before it owns all the traffic

A worker that started is not the same as an application that works. A missing environment variable will let Node boot perfectly and fail every request that touches the database. So the deploy should not finish on "the command exited zero", it should finish on "an HTTP request came back healthy".

Add a health endpoint that exercises the things that actually break, not one that returns {ok: true} unconditionally. It should touch the database, the cache and any data integrations the service depends on, because those are the dependencies that go missing when config drifts between environments.

app.get('/healthz', async (req, res) => {
  if (shuttingDown) return res.status(503).json({status: 'draining'})
  try {
    await db.query('select 1')
    await cache.ping()
    res.json({status: 'ok'})
  } catch (err) {
    res.status(503).json({status: 'unhealthy'})
  }
})

Returning 503 while shuttingDown is true is the part people leave out, and it matters if anything upstream is load balancing. It tells the balancer to stop sending new traffic to this instance immediately, while in flight requests keep draining, instead of waiting for health checks to time out.

What about database migrations

Everything above gets your code out with no dropped requests. Migrations can still take the site down, and they are the most common reason a supposedly zero downtime deploy is not.

The problem is that during a reload, old and new workers run at the same time. A migration that drops or renames a column the old workers still select from will break every request they serve until they are gone. The answer is to make each schema change compatible with both versions of the code: add the new column, deploy code that writes to both, backfill, deploy code that reads only the new one, then drop the old column in a later release. Our guide to zero downtime database migrations walks through the expand and contract pattern with the Postgres and MySQL specifics.

The full deploy, in order

Putting it together, a Node.js app deployment that does not drop requests runs in this sequence. Order is the whole trick.

  1. Clone the commit into releases/<timestamp>.
  2. Symlink the shared paths: .env, uploads, logs.
  3. Run npm ci and your build step, inside the new release directory.
  4. Run any expand phase migrations, the ones safe for both old and new code.
  5. Move the current symlink to the new release. This is the atomic switch.
  6. Run pm2 reload api --update-env. New workers boot, signal ready, old workers drain.
  7. Poll /healthz until it returns 200, with a timeout.
  8. If it never goes healthy, move the symlink back and reload again. That is your rollback, and it takes about a second.
  9. Prune old releases, keeping the last five or so.

That is roughly forty lines of bash, and plenty of teams write it once and never touch it again. The reason to reach for deployment automation software instead is not that the script is hard, it is that the script is invisible: it lives on one machine, only its author fully understands it, and it has no deploy log to consult when something goes wrong at 2am. A tool that runs the same pipeline gives you the history, the rollback button and a second server that works identically to the first.

The pattern here is not specific to Node either. The same atomic release model is what makes zero downtime deployment work for a Laravel application or a Magento store, with the build commands and the reload step swapped for whatever the stack needs.

Ready to stop managing servers by hand? DeployManage provisions, deploys and monitors your fleet from one dashboard.

$ get started free

Frequently asked questions

How do I deploy a Node.js app with zero downtime?

Build each release in its own timestamped directory, move a symlink to it atomically, then run pm2 reload rather than pm2 restart. Reload starts new workers and only stops the old ones once the new ones are listening. Add wait_ready so PM2 waits for your app to finish connecting to its dependencies, and a SIGINT handler so in flight requests drain.

What is the difference between pm2 reload and pm2 restart?

pm2 restart kills the processes and starts them again, so nothing is listening in between and requests are refused. pm2 reload overlaps them: it spawns replacement workers, waits for them, then shuts down the old ones, which the PM2 docs describe as a 0-second-downtime reload. Reload only works in cluster mode, not fork mode.

Does pm2 reload work without cluster mode?

No. In fork mode there is only one process, so PM2 cannot overlap old and new, and reload falls back to behaving like a restart. Start your app with an instance count, for example pm2 start app.js -i max, or set exec_mode to cluster in your ecosystem file. This is the most common reason a reload still causes downtime.

What is wait_ready in PM2?

wait_ready tells PM2 not to treat a worker as online until the app explicitly signals readiness with process.send('ready'). Without it, PM2 considers a worker ready as soon as the process spawns, which is before your database and cache connections exist. PM2 waits 3000ms for that signal by default, which you raise with listen_timeout.

Why does my Node app still drop requests during deploy?

Usually one of three reasons: the app runs in fork mode so reload is really a restart, there is no SIGINT handler so workers die mid request, or npm install runs in the live directory so modules are inconsistent while workers are still serving. Fix all three, since each one alone still leaves dropped requests.

Should I use npm ci or npm install when deploying?

Use npm ci. It installs strictly from package-lock.json and starts from a clean node_modules, so the release matches exactly what you tested. npm install can resolve different versions than your lockfile pins, which means the code running in production is not quite the code you verified in CI.

How do I roll back a Node.js deployment?

Keep the previous releases on disk. Rolling back is then moving the current symlink to the last known good release directory and running pm2 reload, which takes about a second. If rollback means redeploying an older commit and waiting for a fresh npm ci and build, your recovery time is the length of your build, which is the wrong thing to discover during an incident.

Do I need nginx in front of a Node.js app?

It is not strictly required, but it is worth it. Nginx terminates TLS, serves static assets without waking your Node process, and with proxy_next_upstream it can retry another worker when one errors or times out during a reload. It also gives you a place to hold traffic that has nothing to do with your application code.

# related

Provision and deploy from one dashboard.

Connect a provider and ship your first zero-downtime deploy in minutes.

$ get started free

# free plan · no credit card required