If you run a Node.js app on your own server, every deploy is a small risk: stop the old process, start the new one, and visitors see errors in between. With PM2 in cluster mode and a Git hook that builds each release in its own folder, you can push code and have it go live with no dropped requests, and roll back in seconds. This playbook is for a Linux VPS or server you manage yourself; it doesn't apply to shared hosting, where you can't run your own process manager.
Push to a bare Git repository on your server. A post-receive hook unpacks the commit into a new releases/TIMESTAMP-SHA folder, installs and builds it, smoke-tests it on a spare port, then switches a current symlink and runs pm2 startOrReload in cluster mode, which replaces workers one at a time. A health check confirms the new revision is answering, and rolls back automatically if it isn't. We tested this exact hook with PM2 7: no failed requests during a good deploy or a broken one.
1. How the pieces fit
repo.git on the server, just as you push to GitHub.current points at the live release. Switching it is a single rename, so it is never half-done.The layout on the server:
/var/www/example-app/
├── repo.git/ # bare repo you push to
├── releases/ # one folder per deploy
│ ├── 20260924110907-b4c1e2a/
│ └── 20260924111530-f7d9a01/
├── shared/.env # secrets, kept outside releases
└── current -> releases/20260924111530-f7d9a012. Prerequisites
- A Linux server where you have SSH access and can install software, running a current LTS distribution.
- Node.js 24 LTS (or 22 LTS). Node.js 20 reached end of life in 2026; don't start new projects on it.
- PM2 installed globally:
sudo npm install -g pm2. The current major version is PM2 7. - A non-root user, here called
deploy, that owns/var/www/example-appand logs in with an SSH key. - A reverse proxy (nginx or Caddy) in front of the app for HTTPS.
3. Make the app reload-friendly
Zero-downtime reloads need a little help from your app: it must say when it is ready, finish in-flight requests when asked to stop, and report which revision it is running.
// server.js
const http = require('node:http');
const fs = require('node:fs');
try { process.loadEnvFile('.env'); } catch { /* no .env: use real env vars */ }
const REVISION = fs.readFileSync('REVISION', 'utf8').trim();
const PORT = Number(process.env.PORT) || 3000;
const server = http.createServer((req, res) => {
if (req.url === '/healthz') {
res.setHeader('content-type', 'application/json');
return res.end(JSON.stringify({ ok: true, revision: REVISION }));
}
res.end('Hello from example-app\n');
});
server.listen(PORT, '127.0.0.1', () => {
if (process.send) process.send('ready'); // tells PM2 this worker can take traffic
});
function shutdown() {
server.close(() => process.exit(0)); // finish in-flight requests
setTimeout(() => process.exit(1), 7000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);With Express or Fastify the idea is the same: call process.send('ready') once the server is listening and your database connection is up, and close the server on SIGINT. The REVISION file is written by the deploy hook; the health check uses it to prove the new code is live, not just that something answers.
4. The PM2 ecosystem file
Commit this file to your repository so every release carries its own copy:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'example-app',
cwd: '/var/www/example-app/current',
script: 'server.js',
exec_mode: 'cluster',
instances: 2,
wait_ready: true,
listen_timeout: 10000,
kill_timeout: 8000,
max_memory_restart: '400M',
env_production: { NODE_ENV: 'production', PORT: 3000 },
}],
};| Option | What it does |
|---|---|
| exec_mode: 'cluster' | Runs several workers sharing one port. Required for a rolling reload; in fork mode a reload is a restart. |
| instances | Number of workers. Use at least 2, or 'max' for one per CPU core. |
| wait_ready / listen_timeout | PM2 waits for process.send('ready'), up to this many milliseconds, before moving to the next worker. |
| kill_timeout | How long an old worker gets to finish its requests before it is killed. |
| max_memory_restart | Restarts a worker that grows past this size, a safety net for memory leaks. |
Set kill_timeout a little longer than your app's own shutdown timer, as above.
5. Create the repository and the hook
On the server, as the deploy user:
mkdir -p /var/www/example-app/{releases,shared}
git init --bare /var/www/example-app/repo.git
nano /var/www/example-app/shared/.env # secrets, chmod 600
chmod 600 /var/www/example-app/shared/.envSave this as /var/www/example-app/repo.git/hooks/post-receive and make it executable with chmod +x:
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/example-app
REPO="$APP/repo.git"
BRANCH=main
PORT=3000
SMOKE_PORT=3100
KEEP=5
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
while read -r oldrev newrev ref; do
[ "$ref" = "refs/heads/$BRANCH" ] || continue
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
REL="$APP/releases/$(date +%Y%m%d%H%M%S)-${newrev:0:7}"
PREV=$(readlink -f "$APP/current" || true)
echo "Building $REL"
# 1. Unpack the pushed commit into a new release folder
mkdir -p "$REL"
git --git-dir="$REPO" archive "$newrev" | tar -x -C "$REL"
echo "$newrev" > "$REL/REVISION"
ln -sfn "$APP/shared/.env" "$REL/.env"
# 2. Install and build. If this fails, the live release is untouched.
cd "$REL"
npm ci
npm run build --if-present
npm prune --omit=dev
# 3. Smoke test: run the new release on a spare port before it goes live
PORT=$SMOKE_PORT node server.js > "$REL/smoke.log" 2>&1 &
SMOKE_PID=$!
smoke=0
for i in $(seq 1 10); do
if curl -fsS "http://127.0.0.1:$SMOKE_PORT/healthz" | grep -q "$newrev"; then smoke=1; break; fi
sleep 1
done
kill "$SMOKE_PID" 2>/dev/null || true
wait "$SMOKE_PID" 2>/dev/null || true
if [ "$smoke" -ne 1 ]; then
echo "Smoke test failed, see $REL/smoke.log. The live release is unchanged." >&2
exit 1
fi
# 4. Switch the current symlink atomically, then reload the workers
ln -sfn "$REL" "$APP/current.tmp"
mv -Tf "$APP/current.tmp" "$APP/current"
pm2 startOrReload "$APP/current/ecosystem.config.js" --env production
# 5. Health check: the NEW revision must be answering
ok=0
for i in $(seq 1 15); do
if curl -fsS "http://127.0.0.1:$PORT/healthz" | grep -q "$newrev"; then ok=1; break; fi
sleep 2
done
if [ "$ok" -ne 1 ]; then
echo "Health check failed, rolling back to $PREV" >&2
if [ -n "$PREV" ]; then
ln -sfn "$PREV" "$APP/current.tmp"
mv -Tf "$APP/current.tmp" "$APP/current"
pm2 startOrReload "$APP/current/ecosystem.config.js" --env production
fi
exit 1
fi
pm2 save
# 6. Keep the newest $KEEP releases, never the live one
LIVE=$(readlink -f "$APP/current")
ls -1dt "$APP"/releases/* | tail -n +$((KEEP + 1)) | while read -r old; do
[ "$old" = "$LIVE" ] || rm -rf "$old"
done
echo "Deployed ${newrev:0:7}"
doneThen, on your computer:
git remote add production deploy@your-server:/var/www/example-app/repo.git
git push production mainThe hook's output appears in your terminal as the push runs. Finally, make PM2 start on boot: run pm2 startup systemd -u deploy --hp /home/deploy, run the sudo command it prints, then pm2 save.
We tested the hook with a release that crashed on start. Without step 3, PM2 swapped the working workers for crashing ones and more than half of our test requests failed until the rollback finished. With step 3, the broken release never went live and no request failed. Keep the smoke test, and extend it to call a real page of your app.
6. Why these details matter
git archiveinstead of checkout gives a clean copy of the commit with no.gitfolder in the release.- The ecosystem file uses
cwd: …/current. When PM2 reloads, each new worker starts in whatevercurrentpoints to at that moment, so the new code is picked up. We confirmed this: after the switch, every worker reported the new revision. - The health check compares revisions. A plain "is it up?" check passes even if the old code is still running. Grepping for the pushed SHA proves the switch happened.
- A failed hook doesn't undo the push. The commit is still in
repo.git; only the deploy stopped. Fix the problem and push again.
7. Roll back by hand
List releases, point current at a good one, and reload:
cd /var/www/example-app
ls -1t releases
ln -sfn releases/20260924110907-b4c1e2a current.tmp && mv -Tf current.tmp current
pm2 startOrReload current/ecosystem.config.js --env production
curl -s http://127.0.0.1:3000/healthzDatabase migrations are the one thing a symlink can't roll back. Make migrations backward compatible (add columns before using them, remove them a release later), so the previous release still works against the new schema.
8. Logs, proxy and everyday commands
pm2 install pm2-logrotate # rotate logs so they don't fill the disk
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
pm2 ls # status of every worker
pm2 logs example-app # live logs
pm2 monit # CPU and memory per workerA minimal nginx site that proxies to the app:
server {
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}With Caddy, example.com { reverse_proxy 127.0.0.1:3000 } also gets you an HTTPS certificate automatically. For a comparison, see nginx vs Caddy.
For Socket.IO or other WebSocket apps, note that PM2's cluster mode has no sticky sessions. Follow Socket.IO's own guidance for PM2, or run a single instance.
9. Running this on Domain India
This playbook needs a server where you install software yourself, which on Domain India means a VPS. VPS plans are self-managed and come with full root access; cPanel isn't offered on a VPS, so choose no panel for a setup like this. Start with SSH key login and the SSH hardening checklist. For a CI-driven alternative to Git hooks, see GitHub Actions deploys to a VPS.
- 1 vCPU
- 2 GB DDR4 RAM
- 64 GB NVMe SSD Storage
- 2 TB Monthly Bandwidth
Two other options may suit you better:
- Shared hosting (cPanel): the Setup Node.js App tool starts and restarts your app for you, with Node.js 22 and 24 available (avoid 20, which is end of life). You don't run PM2 there. See deploying a Node.js app on shared hosting.
- App Platform: Node.js apps are detected automatically and there's no server to manage. Deploys run when you click Deploy Now or call a deploy token from CI, not on every push, and WebSockets aren't supported. See getting started with the App Platform.
The card price is a live Domain India list price and excludes 18% GST.
Does pm2 reload really give zero downtime?
In cluster mode with at least two instances, yes: PM2 replaces workers one at a time, so another worker keeps answering. In fork mode, or with a single instance, a reload behaves like a restart and requests can fail for a moment.
What is the difference between pm2 reload and pm2 restart?
pm2 restart stops every worker and starts them again, which causes a brief outage. pm2 reload replaces cluster-mode workers one by one and waits for each new worker to be ready before stopping the old one.
Why does my Git hook say node or pm2 not found?
Hooks run in a non-interactive shell that doesn't load your profile or nvm. Set PATH at the top of the hook to include the folder where node and pm2 are installed, as the example does.
How do I roll back a PM2 deployment?
Point the current symlink at a previous release folder and run pm2 startOrReload with the ecosystem file. Check /healthz to confirm the old revision is answering. Database changes must be backward compatible for this to work.
Can I use PM2 on shared hosting?
Not on Domain India shared hosting. There, the control panel's Node.js tool manages your app's process. Use a VPS if you want to run PM2 yourself, or the App Platform if you want no server to manage.
Which Node.js version should I use with PM2 in 2026?
Use a current LTS release: Node.js 24, or 22. Node.js 20 is end of life and no longer receives security fixes. PM2 7 supports Node.js 18 and later.
Ready to ship without downtime? Set up SSH key login on your server, then follow the steps above, or compare VPS plans and the App Platform.
Self-managed VPS with full root access, for PM2, Git-hook deploys and your own reverse proxy.
See VPS plans