Skip to content

Commit 73d84f3

Browse files
committed
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.
1 parent 57f3347 commit 73d84f3

28 files changed

Lines changed: 1114 additions & 88 deletions

.claude/skills/shipnode/REFERENCE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,7 @@ All commands accept `--config <path>` to use a non-default config file.
9292
| `.nodeVersion(v)` | `lts` | Node version via mise |
9393
| `.pkgManager(pm)` | auto-detected | `npm` \| `yarn` \| `pnpm` \| `bun` |
9494
| `.buildDir(dir)` | auto-detected | Frontend build output directory |
95-
| `.zeroDowntime({ keepReleases? })` | true, 5 releases | Zero-downtime mode |
96-
| `.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. |
9796
| `.healthCheck(path, opts?)` | `/health`, 30s, 3 retries | Post-deploy check |
9897
| `.noHealthCheck()` || Skip health check |
9998
| `.envFile(f)` | `.env` | Local .env file to upload |

.claude/skills/shipnode/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ Frontend app: swap `.backend()` → `.frontend()`, add `.buildDir('dist')`, drop
4343
3. `shipnode env` — uploads .env
4444
4. `shipnode deploy`
4545

46-
### Zero-downtime is the default
46+
### Releases + zero-downtime
4747
Releases live in `/remotePath/releases/<timestamp>/`. Current symlink is atomically switched.
48-
To opt out: add `.legacy()` to the builder.
48+
Backend apps can opt into blue-green with `.zeroDowntime()` — boot idle colour, health-check, then flip Caddy.
4949

5050
### Rollback
5151
```bash

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to `@devalade/shipnode` will be documented here.
44

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+
515
## [3.1.0] - 2026-07-02
616

717
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).

CONTEXT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ A symlink pointing to the active release. In zero-downtime mode, `/remotePath/cu
2727
Owns release directory creation, symlink switching, release recording, and cleanup. Operates through a `RemoteExecutor`.
2828

2929
### 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.
3131

3232
### Zero-Downtime
3333
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
3636
The deployment mode where files are rsynced directly to the remote path. No releases, no symlinks, no rollback.
3737

3838
### 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.
4040

4141
### DeploymentStrategy
4242
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.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# 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`.

src/cli/commands/rollback.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import { runRemoteCommandForConfig } from '../runner.js';
22
import { ReleaseManager } from '../../domain/release/manager.js';
33
import { HealthCheckService } from '../../services/health.service.js';
4+
import { CaddyService } from '../../services/caddy.service.js';
45
import { ui } from '../ui.js';
56
import { confirm } from '../prompt.js';
67
import { loadConfig } from '../../config/loader.js';
78
import { getActiveApp } from '../../domain/workspace.js';
8-
import { getDeploymentName, getEcosystemPath } from '../../domain/pm2/apps.js';
9+
import { getDeploymentName, getEcosystemPath, getWebApp } from '../../domain/pm2/apps.js';
10+
import {
11+
readDeployState,
12+
writeDeployState,
13+
otherColor,
14+
portFor,
15+
coloredWebName,
16+
} from '../../domain/deploy/blue-green.js';
17+
import type { RemoteExecutor } from '../../domain/remote/executor.js';
18+
import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js';
919
import { configForAppResult } from '../../domain/servers.js';
1020

1121
export async function cmdRollback(
@@ -30,9 +40,17 @@ export async function cmdRollback(
3040
async ({ config, executor }) => {
3141
const app = getActiveApp(config, options.app);
3242
const appPath = `${config.remotePath}/${app.name}`;
33-
const releases = new ReleaseManager(executor, appPath, app.keepReleases);
3443
const stepsBack = options.steps ?? 1;
3544

45+
// Blue-green apps roll back by flipping Caddy back to the previous colour,
46+
// which is still running — instant and zero-drop. No pm2 restart.
47+
if (app.appType === 'backend' && app.zeroDowntime) {
48+
await rollbackBlueGreen(executor, config, app, appPath, stepsBack);
49+
return;
50+
}
51+
52+
const releases = new ReleaseManager(executor, appPath, app.keepReleases);
53+
3654
ui.info('Fetching release history...');
3755
const allReleases = await releases.listReleases();
3856

@@ -89,3 +107,75 @@ export async function cmdRollback(
89107
},
90108
);
91109
}
110+
111+
/**
112+
* Instant blue-green rollback: the previous colour is still resident, so we
113+
* flip Caddy's upstream back to it and swap the persisted active colour. No
114+
* process restart, no dropped requests.
115+
*
116+
* Only one step back is possible — older colours were reaped by later deploys.
117+
* For anything deeper, redeploy the desired release instead.
118+
*/
119+
async function rollbackBlueGreen(
120+
executor: RemoteExecutor,
121+
config: ShipnodeConfig,
122+
app: ShipnodeApp,
123+
appPath: string,
124+
stepsBack: number,
125+
): Promise<void> {
126+
if (stepsBack !== 1) {
127+
throw new Error(
128+
`Blue-green rollback only supports one step (the live previous colour). ` +
129+
`To go further back, redeploy the desired release.`,
130+
);
131+
}
132+
133+
const state = await readDeployState(executor, appPath);
134+
if (!state) {
135+
throw new Error('No blue-green state found on the server — nothing to roll back to.');
136+
}
137+
138+
const webApp = getWebApp({ ...config, apps: [app] } as ShipnodeConfig);
139+
if (!webApp) {
140+
throw new Error('No web app (pm2 app with a port) found for this deployment.');
141+
}
142+
const namespace = app.pm2?.apps[0]?.name ?? app.name;
143+
const previous = otherColor(state.activeColor);
144+
const previousPort = portFor(previous, state);
145+
const previousName = coloredWebName(namespace, webApp.name, previous);
146+
147+
// The previous colour must still be online to serve traffic after the flip.
148+
// Parse pm2's JSON in-process rather than relying on `node` being on the
149+
// remote PATH at rollback time.
150+
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
151+
const jlist = await executor.exec(`${mise} && mise exec -- pm2 jlist`);
152+
let online = false;
153+
try {
154+
const entries = JSON.parse(jlist.stdout.trim()) as Array<{ name: string; pm2_env?: { status?: string } }>;
155+
online = entries.some((e) => e.name === previousName && e.pm2_env?.status === 'online');
156+
} catch {
157+
online = false;
158+
}
159+
if (!online) {
160+
throw new Error(
161+
`Previous colour "${previousName}" is not running — cannot instant-rollback. ` +
162+
`Redeploy the desired release instead.`,
163+
);
164+
}
165+
166+
ui.warn(`Active colour: ${state.activeColor} (port ${portFor(state.activeColor, state)})`);
167+
ui.warn(`Rollback target: ${previous} (port ${previousPort})`);
168+
169+
const ok = await confirm('Flip traffic back to the previous colour?');
170+
if (!ok) {
171+
ui.info('Rollback cancelled.');
172+
return;
173+
}
174+
175+
const caddy = new CaddyService(executor, config);
176+
await caddy.configureBackend(app, previousPort);
177+
await caddy.reload();
178+
await writeDeployState(executor, appPath, { ...state, activeColor: previous });
179+
180+
ui.success(`Rolled back — traffic now on ${previous} (port ${previousPort})`);
181+
}

src/cli/commands/unlock.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ export async function cmdUnlock(
99
await runRemoteCommandForTargets(
1010
cwd,
1111
async ({ config, executor, serverName }) => {
12-
const lockFile = `${config.remotePath}/.shipnode/deploy.lock`;
12+
const lockPath = `${config.remotePath}/.shipnode/deploy.lock`;
1313

1414
ui.info(`Checking for deployment lock on ${serverName} (${config.ssh.user}@${config.ssh.host})...`);
1515

1616
const result = await executor.exec(
17-
`if [ -f "${lockFile}" ]; then ` +
18-
`age=$(( $(date +%s) - $(stat -c %Y "${lockFile}") )); ` +
17+
`if [ -e "${lockPath}" ]; then ` +
18+
`age=$(( $(date +%s) - $(stat -c %Y "${lockPath}") )); ` +
1919
`echo "FOUND:$age"; ` +
2020
`else echo "NOTFOUND"; fi`,
2121
);
@@ -34,7 +34,7 @@ export async function cmdUnlock(
3434
return;
3535
}
3636

37-
await executor.exec(`rm -f "${lockFile}"`);
37+
await executor.exec(`rm -rf "${lockPath}"`);
3838
ui.success('Deployment lock cleared.');
3939
},
4040
{ configPath: options.config, includeEmpty: true },

src/cli/monitor/poller.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@ function buildSystemSection(): string {
3333
].join('; ');
3434
}
3535

36-
function buildLockSection(lockFile: string): string {
36+
function buildLockSection(lockPath: string): string {
37+
// Directory lock (atomic mkdir) stores the timestamp in acquired;
38+
// legacy file locks store it as the file contents.
3739
return (
38-
`if [ -f "${lockFile}" ]; then ` +
39-
`echo "$(cat "${lockFile}" 2>/dev/null) $(( $(date +%s) - $(stat -c %Y "${lockFile}" 2>/dev/null || date +%s) ))"; ` +
40+
`if [ -d "${lockPath}" ]; then ` +
41+
`echo "$(cat "${lockPath}/acquired" 2>/dev/null) $(( $(date +%s) - $(stat -c %Y "${lockPath}" 2>/dev/null || date +%s) ))"; ` +
42+
`elif [ -f "${lockPath}" ]; then ` +
43+
`echo "$(cat "${lockPath}" 2>/dev/null) $(( $(date +%s) - $(stat -c %Y "${lockPath}" 2>/dev/null || date +%s) ))"; ` +
4044
`else echo "none"; fi`
4145
);
4246
}

src/config/assembly.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ type AssembleInput = {
3434
healthCheck?: unknown;
3535
envFile?: string;
3636
keepReleases?: number;
37+
zeroDowntime?: boolean;
38+
altPort?: number;
3739
sharedDirs?: string[];
3840
sharedFiles?: string[];
3941
buildDir?: string;

src/config/builder.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ type BuilderState = {
3838
healthCheck?: Partial<HealthCheckConfig>;
3939
envFile?: string;
4040
buildDir?: string;
41+
zeroDowntime?: boolean;
42+
altPort?: number;
4143
sharedDirs?: string[];
4244
sharedFiles?: string[];
4345
appRoot?: string;
@@ -130,6 +132,16 @@ export class ShipnodeBuilder {
130132
return this;
131133
}
132134

135+
/**
136+
* Enable zero-downtime (blue-green) releases for the web app. Optional
137+
* `altPort` sets the green port (defaults to web port + 1).
138+
*/
139+
zeroDowntime(altPort?: number): this {
140+
this.config.zeroDowntime = true;
141+
if (altPort !== undefined) this.config.altPort = altPort;
142+
return this;
143+
}
144+
133145
sharedDirs(dirs: string[]): this {
134146
this.config.sharedDirs = dirs;
135147
return this;
@@ -343,6 +355,16 @@ export class ShipnodeAppBuilder {
343355
return this;
344356
}
345357

358+
/**
359+
* Enable zero-downtime (blue-green) releases for this web app. Optional
360+
* `altPort` sets the green port (defaults to web port + 1).
361+
*/
362+
zeroDowntime(altPort?: number): this {
363+
this.state.zeroDowntime = true;
364+
if (altPort !== undefined) this.state.altPort = altPort;
365+
return this;
366+
}
367+
346368
sharedDirs(dirs: string[]): this {
347369
this.state.sharedDirs = dirs;
348370
return this;

0 commit comments

Comments
 (0)