You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add blue-green zero-downtime deploys with safer failure recovery.
Opt-in colour swap behind Caddy keeps the old release serving until health passes; workers reload only after that, failed deploys revert current and record history, and the deploy lock is atomic via mkdir.
|`.legacy()`| — | In-place rsync deploy, no rollback |
95
+
|`.zeroDowntime(altPort?)`| off; green = web port + 1 | Blue-green releases: boot the idle colour, health-check it, reload workers, then flip Caddy. Requires a web app (a `.port`). `altPort` sets the green port. |
Copy file name to clipboardExpand all lines: CHANGELOG.md
+10Lines changed: 10 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,6 +2,16 @@
2
2
3
3
All notable changes to `@devalade/shipnode` will be documented here.
4
4
5
+
## [Unreleased]
6
+
7
+
### Added
8
+
-**Blue-green (zero-downtime) releases** — opt a backend web app in with `.zeroDowntime(altPort?)`. The new release boots on the idle colour's port while the old colour keeps serving; the health check runs against the new colour, and only on success does Caddy's upstream flip via a graceful `systemctl reload caddy` (drains in-flight requests, drops nothing). A failed health check leaves the old colour serving untouched — zero user impact on a bad release. Rollback becomes an instant Caddy flip back to the still-running previous colour (no restart). Off by default; the recreate path is unchanged. Blue = the web app's `port`, green = `altPort` (default `port + 1`). Web app runs ~2× (both colours resident between deploys); workers stay a single reloaded set. See ADR-0005.
9
+
10
+
### Fixed
11
+
-**Blue-green workers wait for health** — workers reload only after the new colour passes health (and before the Caddy flip), so a bad release no longer leaves workers on new code while traffic stays on the old colour.
12
+
-**Failed deploys revert `current` and record history** — on failure after the symlink switch, `current` is restored to the previous release (when one exists) and a `status: 'failed'` entry is written to `releases.json`.
13
+
-**Atomic deploy lock** — lock acquisition uses `mkdir` (create-or-fail) instead of a TOCTOU file check. Path is still `.shipnode/deploy.lock` (now a directory); `unlock` / monitor handle both directory and legacy file shapes.
14
+
5
15
## [3.1.0] - 2026-07-02
6
16
7
17
First real production run of 3.0 surfaced a batch of bugs — all fixed — plus one new feature (incremental restic backups to Cloudflare R2 / any S3-compatible store).
Copy file name to clipboardExpand all lines: CONTEXT.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -27,7 +27,7 @@ A symlink pointing to the active release. In zero-downtime mode, `/remotePath/cu
27
27
Owns release directory creation, symlink switching, release recording, and cleanup. Operates through a `RemoteExecutor`.
28
28
29
29
### DeployLock
30
-
A PID-based lock file preventing concurrent deployments. Lives at `/remotePath/.shipnode/deploy.lock`.
30
+
An atomic `mkdir`-based lock preventing concurrent deployments. Lives at `/remotePath/.shipnode/deploy.lock/` (directory). Legacy file-shaped locks are still recognised.
31
31
32
32
### Zero-Downtime
33
33
The deployment mode where a new release is staged, then atomically switched via symlink. The old release remains available until the switch succeeds and health checks pass.
@@ -36,7 +36,7 @@ The deployment mode where a new release is staged, then atomically switched via
36
36
The deployment mode where files are rsynced directly to the remote path. No releases, no symlinks, no rollback.
37
37
38
38
### DeployOrchestrator
39
-
Owns the invariant deployment sequence: lock → release → stage → setup → hooks → symlink → start → health check → record → cleanup → unlock. Knows nothing about app-specific details.
39
+
Owns the invariant deployment sequence: lock → release → stage → setup → hooks → symlink → start → health check → afterHealthy → caddy flip → record → cleanup → unlock. On failure: revert `current` symlink (when a previous release exists) and record a failed release. Knows nothing about app-specific details.
40
40
41
41
### DeploymentStrategy
42
42
An adapter that knows how to stage, prepare, and start a specific kind of application (backend, frontend, etc.). Plugs into the orchestrator at fixed lifecycle hooks.
# Blue-green releases via a two-port colour swap behind Caddy
2
+
3
+
The default deploy strategy stops the whole PM2 process set and starts the new release on the same port (`pm2 delete … && pm2 start …`). Between the delete and the moment the new process is listening — install relink, framework boot, first health probe — the port is closed and inbound requests are refused. For a plain web app that is a multi-second window of dropped requests on every deploy.
4
+
5
+
`zeroDowntime` opts a backend web app into blue-green releases that close that window.
6
+
7
+
## Mechanism
8
+
9
+
Two ports, named **blue** and **green**. Blue is the web app's configured `port`; green is `altPort` (default `port + 1`). Exactly one colour is *active* at a time — Caddy's `reverse_proxy` upstream points at it. The active colour and the port pair are persisted on the host in `<appPath>/.shipnode/deploy-state.json`.
10
+
11
+
Each deploy:
12
+
13
+
1. Stages the release and switches the `current` symlink (as always). The previously-started colour keeps running the old code in memory — the symlink move doesn't touch a running process.
14
+
2. Boots the **idle** colour on its own port (`pm2 start ecosystem.web.cjs`), reaping any stale same-colour instance first. The active colour is never touched. Workers are **not** reloaded yet.
15
+
3. Health-checks the new colour on **its** port and colour-suffixed pm2 name (`api-green`).
16
+
4. Only on success: reloads the single worker set against the new release, then rewrites the Caddy site to the new colour's port and `systemctl reload caddy` — a graceful reload that drains in-flight requests, so the flip drops nothing. Then persists the new active colour.
17
+
18
+
A failed health check throws before step 4: Caddy is untouched, workers stay on the previous release, the old colour is still serving, `current` is reverted to the previous release when one exists, and the failed colour is reaped by the next deploy. Zero user impact on a bad release — the main prize.
19
+
20
+
## Why the web app is duplicated but workers are not
21
+
22
+
Blue-green needs two copies of the *web* app (old + new, different ports) resident at once. Workers have no port and aren't behind Caddy; running two copies would double-process their queues. So the ecosystem is split: `ecosystem.web.cjs` holds only the colour-suffixed web app; `ecosystem.workers.cjs` holds the workers as a single set, reloaded in place **after** health passes (a brief worker blip on a successful deploy is acceptable — they're not serving HTTP). Reloading before health would leave workers on a bad release while traffic stayed on the old colour.
23
+
24
+
## Rollback is a flip, not a restart
25
+
26
+
Because the previous colour is still running, rollback is an instant Caddy flip back to it plus a state swap — no process restart, nothing dropped. Only one step back is supported (older colours were reaped); deeper rollbacks redeploy the target release the normal way.
27
+
28
+
## Trade-offs
29
+
30
+
-**~2× memory for the web app** — both colours are resident between deploys. Documented; the price of instant rollback.
31
+
-**The port pair is fixed at the first deploy** and persisted. Later changes to the web port or `altPort` in config are ignored until the state file is cleared, so a running colour is never silently re-homed.
32
+
-**Migration costs one restart.** The first `zeroDowntime` deploy deletes the pre-existing uncoloured web process (which holds the blue == web port) by name so the target colour can bind. After that, every deploy is zero-drop.
33
+
-**Opt-in, default off.** The recreate path is byte-for-byte unchanged, so existing deployments are unaffected until they set `zeroDowntime`.
0 commit comments