pm2-release
v0.2.1
Published
Health-gated deploys and rollback for PM2. Rolling releases that stop and revert when the new code fails its probes.
Maintainers
Readme
pm2-release
Health-gated deploys and rollback for PM2. Rolls a release out one instance at a time, checks each one before touching the next, and puts the previous release back when the new code fails.
pm2 reload restarts every instance and reports success if the processes came back. It has
no idea whether the new code works, and by the time you find out, the whole fleet is running
it. pm2-release keeps the rest of the fleet on the old release while the new one is judged,
so a bad deploy costs one instance instead of all of them.
npm install -g pm2-release
pm2-release deploy api --from ./build --root /srv/apiSee it work
A real two-instance cluster app, captured from a live PM2 daemon. The first release is good; the second serves 503 from its health endpoint.
$ pm2-release deploy api --from ./build-good --root /srv/api --json
{"ok":true,"releaseId":"20261103-024537-3hoym9","previousId":"20261103-024532-p7ha56",
"rolledBack":false,"rollout":{"ok":true,"reloaded":[0,1]},"pruned":[]}
$ pm2-release deploy api --from ./build-bad --root /srv/api --json
{"ok":false,"releaseId":"20261103-024600-u314us","previousId":"20261103-024537-3hoym9",
"rolledBack":true,
"rollout":{"ok":false,"reloaded":[],"failedAt":0,
"reason":"instance 0 did not become healthy: status 503 (via http)"},
"reason":"instance 0 did not become healthy: status 503 (via http)"}
$ echo $?
1"failedAt":0 with "reloaded":[] is the point: it stopped at the first instance.
Instance 1 never ran the bad code, and current went back to the release that works.
The non-zero exit is what makes CI go red without extra plumbing.
$ pm2-release list api --root /srv/api
20261103-024600-u314us 2026-11-02T21:47:00.545Z (rejected)
* 20261103-024537-3hoym9 2026-11-02T21:45:37.465Z
20261103-024532-p7ha56 2026-11-02T21:45:32.012ZHow a release is laid out
The Capistrano shape, which PM2 supports by accident and reliably:
/srv/api/
releases/
20261103-020659-dqwws0/ the code
20261103-021037-x0ar6q/ an older release, kept for rollback
current -> releases/20261103-020659-dqwws0Start your app through the symlink, once:
// ecosystem.config.js
module.exports = { apps: [{
name: 'api',
script: '/srv/api/current/app.js',
cwd: '/srv/api/current',
instances: 2,
exec_mode: 'cluster',
increment_var: 'PORT', // see "Cluster mode needs per-instance ports"
env: { PORT: 3000 },
}] };This works because PM2 stores the symlink in pm_exec_path and pm_cwd rather than
resolving it, so a reload picks up whatever current points at now. That was verified
against PM2 7.1.1 rather than assumed; it is finding F16 in
pm2-ops-contract.
Seeding an empty root
The first deploy into an empty root has nothing to reload, so it reports failure with
no running instances. That is expected: it still writes the release and points current
at it, which is what you then start the app from.
pm2-release deploy api --from ./build --root /srv/api # writes the release, exits 1
pm2 start ecosystem.config.js # starts through /srv/api/current
pm2-release deploy api --from ./build --root /srv/api # gated from here onA release the gate never got to judge is not marked as rejected, so it stays a valid rollback target.
A deploy is then: write the new release, swap the symlink, reload one instance, check it, repeat. A rollback is the same thing pointed at an older directory.
Getting the code onto the box
Two sources. The directory is the one to use.
# whatever your CI already produces: rsync, scp, an artifact download, a container copy
pm2-release deploy api --from ./build --root /srv/api
# or let it clone, for setups with no artifact pipeline
pm2-release deploy api --git [email protected]:you/api.git --ref main --install --root /srv/apiGit mode does a shallow clone, optionally runs npm ci --omit=dev (falling back to
npm install with no lockfile), and appends the short commit sha to the release id so a
running release is traceable to a commit. It then deletes .git from the release: a
deployed release is not a working copy, and .git/config holds the remote URL, which in CI
routinely carries a token.
The health gate
Three sources, tried in order, first usable one wins:
| Source | What it is | When it applies |
| --- | --- | --- |
| probe | pm2-liveness's own verdict, over PM2's bus | that module is installed and has judged this instance |
| http | GET /health on the instance's own port | the app has a per-instance PORT |
| uptime | came back, stayed up, did not crash again | always; weak, and honest about it |
probe is first on purpose. pm2-liveness already runs the real probes with the operator's
own thresholds, and two health opinions disagreeing mid-deploy is the worst possible time
for it. If that module is absent, nothing arrives on the bus and the gate falls through.
Each instance must stay healthy for soakSeconds before the next one is touched. An
instance that never gets there inside waitSeconds fails the release.
What the uptime gate can and cannot tell you
It catches a release that crashes on boot or crash-loops. It cannot catch a release that
starts cleanly and serves garbage. If you have no pm2-liveness and no health endpoint,
that is the guarantee you are getting, and the README is not going to pretend otherwise.
--no-gate goes further and skips health entirely: it waits only for the process to come
back. That is a deploy, not a gated release.
A note on one subtlety, because it caused a bug: PM2 increments
restart_timeby exactly one on a reload, in both cluster and fork mode. The uptime gate therefore compares against "the count we expect once our own reload lands", not the count from before it. Comparing against the earlier number reportsrestarted 1 time(s)on every successful deploy, which made--no-gateroll every release back.
Cluster mode needs per-instance ports
The http gate asks a specific instance whether it is healthy. In cluster mode every
instance listens on the same port by default, so the request is answered by whichever one
the kernel picks, and the answer says nothing about the instance just reloaded.
increment_var: 'PORT' gives each instance its own port and makes the check meaningful.
Without it, pm2-release skips the http source rather than trusting a result it knows
is meaningless, and falls through to uptime.
Commands
pm2-release deploy <app> --from <dir>
pm2-release deploy <app> --git <url> [--ref main] [--install]
pm2-release rollback <app> [--to <release-id>]
pm2-release list <app>| Flag | Meaning |
| --- | --- |
| --root <dir> | where releases/ and current live (required unless configured) |
| --config <file> | YAML or JSON file with per-app defaults |
| --no-gate | reload without waiting for health |
| --no-rollback | stop on failure instead of reverting |
| --keep <n> | releases to retain besides the current one (default 5) |
| --json | machine-readable result on stdout |
Rollback picks the release that was actually running
rollback with no --to does not mean "the newest directory that is not current".
A deploy that fails its gate leaves its directory behind, so that rule rolls you forward
onto the exact code you just refused to run. Rejected releases are tombstoned on disk and
are never a rollback target; the default target is the predecessor recorded by the last
successful deploy. A second rollback walks further back rather than bouncing between
the same two releases.
A tombstoned release is still reachable by name with --to, and is the first thing pruned.
Configuration file
Everything can come from flags. A file is for when you have more than one app.
# /etc/pm2-release.yaml
logLevel: info
apps:
api:
root: /srv/api
keep: 5
rollbackOnFailure: true
gate:
order: [probe, http, uptime]
path: /health
host: 127.0.0.1
expectStatus: [200, 399]
timeoutMs: 3000
soakSeconds: 10 # healthy for this long before moving to the next instance
waitSeconds: 60 # give up on an instance after this long
watch:
enabled: false
soakMinutes: 15
autoRollback: false
failuresBeforeRollback: 3
intervalSeconds: 10An invalid file is rejected with the offending path named, rather than being silently replaced by defaults.
The watcher (optional)
A gate that passes proves the new code starts and answers. It does not prove the code survives real traffic, a slow leak, or a dependency that only fails under load.
Installed as a PM2 module, pm2-release runs a small resident process that keeps watching
a release for soakMinutes after it goes live and can put the previous one back.
pm2 install pm2-release
pm2 set pm2-release:configFile /etc/pm2-release.yaml
pm2 set pm2-release:watchEnabled trueIt uses the same health sources as the gate, and needs
failuresBeforeRollback consecutive bad checks before it acts. One refused connection
during a GC pause is not a reason to revert production.
autoRollback is off by default. A deploy reverting itself unattended, minutes later,
is a surprise in the middle of someone else's incident unless they asked for it. Left off,
the watcher logs the degradation and does nothing else.
If the app has neither pm2-liveness nor a per-instance port, the watcher says so once and
stays quiet, because there is nothing it could detect.
The config file is re-read in place, so changing autoRollback during an incident does not
mean restarting the module. It is polled by inode, mtime and size rather than watched with
fs.watch: an editor or sed -i replaces the inode, and fs.watch follows the old one and
is permanently dead after the first edit. That was verified across every module in this suite.
Events
Nothing is published yet. The deploy marker at <root>/.pm2-release-state.json records
what is running, what preceded it, and when, and is the contract between the CLI and the
watcher. The two halves are deliberately decoupled: the CLI has to work with no watcher
running, which is the normal CI case.
Use with the rest of the suite
| Module | Relationship |
| --- | --- |
| pm2-liveness | supplies the strongest health gate; install it first if you can |
| pm2-alertmanager | pages a human when a release degrades after deploy |
| pm2-shipper | keeps the logs of a release that was rolled back |
None of them are required. pm2-release works on its own, with a weaker gate.
Troubleshooting
Every pm2 command hangs and never returns
PM2_HOME is too long. PM2 binds $PM2_HOME/rpc.sock, and Linux caps unix socket paths at
107 characters. Over that the bind fails silently: no socket is created, every command
blocks forever, and the orphaned daemon spins at 100% CPU.
echo -n "$PM2_HOME/rpc.sock" | wc -c # must be < 107The deploy fails with no usable health source
No pm2-liveness verdict, no per-instance port, and uptime is not in gate.order. Add
uptime to the order, add increment_var: 'PORT', or install pm2-liveness.
A crash-looping release takes the full waitSeconds to fail
It does, by design. An app that crashes once on boot while it waits for a dependency and
then comes up fine is a real pattern, so one extra restart is not treated as fatal. Lower
waitSeconds if you would rather fail fast.
The app still runs the old code after a deploy
It was not started through the symlink. Check pm2 jlist for pm_exec_path: it must contain
/current/, not a resolved release directory. PM2 keeps whatever path it was given at start
time, so an app started directly against a release directory stays pinned to it forever.
Design documentation
docs/requirements.md- what this module was built against, including the requirements that were revised once the implementation met a live daemon.- Platform findings F16 and F17, which this module depends on, are in
pm2-ops-contract.
Requirements
Node 22 or newer - tested on 22, 24 and 26 - with PM2 5 or newer.
Development
npm install
npm run build
npm testThe end-to-end behaviour is exercised by scenario 60 in the suite lab
(pm2-ops-contract/lab), which runs a real two-instance cluster app against a real PM2
daemon, deploys a good release and a bad one, and asserts that the bad one is caught at the
first instance and reverted.
The rest of the suite
pm2-release is one of five independent PM2 modules. They install, version and run separately -
none depends on another - but they are built against one shared set of verified PM2
platform findings, so they behave consistently and do not fight each other.
| Module | What it does |
| --- | --- |
| pm2-liveness | real health probes and a verdict on the bus |
| pm2-alertmanager | crash and probe alerts, deduplicated and routed |
| pm2-shipper | log shipping that survives rotation |
| pm2-secrets | secrets fetched from a vault and injected at restart |
pm2-liveness publishes a verdict on the PM2 bus that pm2-alertmanager and pm2-release
both consume, which is the one place the modules genuinely talk to each other.
License
MIT
