Git Deployment

ZeroDowntime Deploys with PM2 + Git Hooks ProductionReady Playbook

By the Domain India teamPublished 11 min read
Knowledge base article
Contents (9 sections)

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.

Key takeaways

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

Bare repository
You push to repo.git on the server, just as you push to GitHub.
Atomic releases
Each push builds into a new folder. The live app keeps running from the old folder until the new one is ready.
Symlink switch
current points at the live release. Switching it is a single rename, so it is never half-done.
Rolling reload
PM2 cluster mode runs several workers on one port and replaces them one by one, so some worker is always answering.

The layout on the server:

text
/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-f7d9a01

2. 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-app and 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.

javascript
// 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:

javascript
// 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 },
  }],
};
OptionWhat it does
exec_mode: 'cluster'Runs several workers sharing one port. Required for a rolling reload; in fork mode a reload is a restart.
instancesNumber of workers. Use at least 2, or 'max' for one per CPU core.
wait_ready / listen_timeoutPM2 waits for process.send('ready'), up to this many milliseconds, before moving to the next worker.
kill_timeoutHow long an old worker gets to finish its requests before it is killed.
max_memory_restartRestarts 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:

bash
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/.env

Save this as /var/www/example-app/repo.git/hooks/post-receive and make it executable with chmod +x:

bash
#!/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}"
done

Then, on your computer:

bash
git remote add production deploy@your-server:/var/www/example-app/repo.git
git push production main

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

Why the smoke test matters

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 archive instead of checkout gives a clean copy of the commit with no .git folder in the release.
  • The ecosystem file uses cwd: …/current. When PM2 reloads, each new worker starts in whatever current points 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:

bash
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/healthz

Database 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

bash
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 worker

A minimal nginx site that proxies to the app:

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

VPS Starter
₹552.65/mo + GST
  • 1 vCPU
  • 2 GB DDR4 RAM
  • 64 GB NVMe SSD Storage
  • 2 TB Monthly Bandwidth
See plan details

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.

Run Node.js on your own server

Self-managed VPS with full root access, for PM2, Git-hook deploys and your own reverse proxy.

See VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app