Skip to content

Commit 5f842bc

Browse files
committed
fix blue-green PM2 startup on alternate ports
1 parent ba7f972 commit 5f842bc

5 files changed

Lines changed: 38 additions & 23 deletions

File tree

docs/adr/0003-source-env-instead-of-pm2-env-file.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ We now drop `env_file` from the ecosystem and emit each app as a `bash` script w
77
```js
88
{
99
script: 'bash',
10-
args: ['-c', "set -a && . '/var/www/app/shared/.env.production' && set +a && exec node dist/server.js"],
10+
args: ['-c', "set -a && . '/var/www/app/shared/.env.production' && set +a && export NODE_ENV='production' PORT='3000' && exec node dist/server.js"],
1111
env: { NODE_ENV: 'production', PORT: 3000 }
1212
}
1313
```
1414

15-
`set -a` exports everything sourced. `exec` replaces the wrapper shell so PM2 supervises the real process directly — signals, reloads, and crash detection behave exactly as before. Per-app `env:` overrides (e.g. `WORKER_QUEUE`) still apply because PM2 layers them on top of the inherited env. Secrets stay in the chmod-600 `.env` file; they do **not** leak into the world-readable `ecosystem.config.cjs`.
15+
`set -a` exports everything sourced. The wrapper then reapplies the ecosystem's explicit values so `NODE_ENV`, per-app overrides, and especially a blue-green target `PORT` take precedence over stale values in the shared dotenv file. `exec` replaces the wrapper shell so PM2 supervises the real process directly — signals, reloads, and crash detection behave exactly as before. Secrets from the dotenv file stay in the chmod-600 shared file; they are not duplicated into the world-readable ecosystem configuration.
1616

1717
`args` is emitted as an array because PM2 word-splits string args on whitespace, which would shred the inline shell snippet.
1818

docs/adr/0005-blue-green-zero-downtime.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ Two ports, named **blue** and **green**. Blue is the web app's configured `port`
99
Each deploy:
1010

1111
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.
12-
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.
12+
2. Boots the **idle** colour on its own port (`pm2 start ecosystem.web.config.cjs`), reaping any stale same-colour instance first. The active colour is never touched. Workers are **not** reloaded yet.
1313
3. Health-checks the new colour on **its** port and colour-suffixed pm2 name (`api-green`).
1414
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. Then persists the new active colour. On the first migration only, the legacy uncoloured process is removed after this sequence.
1515

1616
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.
1717

1818
## Why the web app is duplicated but workers are not
1919

20-
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.
20+
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.config.cjs` holds only the colour-suffixed web app; `ecosystem.workers.config.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). The `.config.cjs` suffix is required so PM2 parses these files as ecosystem configuration instead of launching the file itself as an application. Reloading before health would leave workers on a bad release while traffic stayed on the old colour.
2121

2222
## Rollback is a flip, not a restart
2323

src/domain/deploy/backend-strategy.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ function escapeSingleQuotes(s: string): string {
1313
return s.replace(/'/g, "\\'");
1414
}
1515

16+
function shellSingleQuote(s: string): string {
17+
return `'${s.replace(/'/g, `'"'"'`)}'`;
18+
}
19+
1620
// Q5: `command` is shell-style — split on whitespace into script + args.
1721
// Omitted command falls back to `<pkgManager> start` (the pre-multi-process default).
1822
function parseCommand(command: string | undefined, pkgManager: string): { script: string; args: string } {
@@ -216,13 +220,13 @@ export class BackendStrategy implements DeploymentStrategy {
216220
const webEco = this.generateEcosystemForApps([webApp], pkgManager, (a) =>
217221
a.name === webApp.name ? { nameSuffix: `-${target.color}`, portOverride: target.port } : {},
218222
);
219-
const webRuntimePath = `${this.appPath}/current/ecosystem.web.cjs`;
220-
await this.writeEcosystem(ctx, `${ctx.workDir}/ecosystem.web.cjs`, webEco);
223+
const webRuntimePath = `${this.appPath}/current/ecosystem.web.config.cjs`;
224+
await this.writeEcosystem(ctx, `${ctx.workDir}/ecosystem.web.config.cjs`, webEco);
221225

222226
// Write workers ecosystem now so afterHealthy can reload it; do not start yet.
223227
if (workers.length > 0) {
224228
const workersEco = this.generateEcosystemForApps(workers, pkgManager);
225-
await this.writeEcosystem(ctx, `${ctx.workDir}/ecosystem.workers.cjs`, workersEco);
229+
await this.writeEcosystem(ctx, `${ctx.workDir}/ecosystem.workers.config.cjs`, workersEco);
226230
}
227231

228232
await this.relinkPackages(ctx, pkgManager, cdPath, mise);
@@ -253,7 +257,7 @@ export class BackendStrategy implements DeploymentStrategy {
253257

254258
const cdPath = `${this.appPath}/current`;
255259
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
256-
const workersRuntimePath = `${this.appPath}/current/ecosystem.workers.cjs`;
260+
const workersRuntimePath = `${this.appPath}/current/ecosystem.workers.config.cjs`;
257261

258262
await ctx.executor.execOrThrow(
259263
`cd "${cdPath}" && ${mise} && ` +
@@ -381,7 +385,14 @@ ${appBlocks.join(',\n')}
381385

382386
if (useWrapper) {
383387
const tail = origArgs ? `${origScript} ${origArgs}` : origScript;
384-
const inner = `set -a && . '${escapeSingleQuotes(envFilePath)}' && set +a && exec ${tail}`;
388+
// Sourcing the dotenv file happens inside the PM2-launched shell, so it
389+
// can overwrite inline PM2 values such as the blue-green port. Re-apply
390+
// the ecosystem's explicit values afterwards to preserve their normal
391+
// precedence over the shared environment.
392+
const explicitEnv = Object.entries(env)
393+
.map(([key, value]) => `${key}=${shellSingleQuote(String(value))}`)
394+
.join(' ');
395+
const inner = `set -a && . '${escapeSingleQuotes(envFilePath)}' && set +a && export ${explicitEnv} && exec ${tail}`;
385396
scriptLine = `script: 'bash',`;
386397
argsLine = `\n args: ['-c', '${escapeSingleQuotes(inner)}'],`;
387398
} else {

tests/unit/backend-strategy.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,10 @@ describe('BackendStrategy.startApp', () => {
424424
expect(writeCmd).toContain(`script: '"'"'bash'"'"',`);
425425
expect(writeCmd).toContain('shared/.env.production');
426426
expect(writeCmd).toContain('set -a');
427+
expect(writeCmd).toContain('export NODE_ENV=');
428+
expect(writeCmd).toContain('PORT=');
429+
expect(writeCmd).toContain('3000');
430+
expect(writeCmd.indexOf('export NODE_ENV=')).toBeGreaterThan(writeCmd.indexOf('set +a'));
427431
expect(writeCmd).toContain('exec');
428432
});
429433

@@ -507,11 +511,11 @@ describe('BackendStrategy.startApp — blue-green', () => {
507511
await strategy.startApp!(makeCtx(executor, { deployTarget: bgTarget() }));
508512

509513
const cmds = executor.getHistory().map((h) => h.command);
510-
const webEco = cmds.find((c) => c.includes('ecosystem.web.cjs') && c.includes('echo'));
514+
const webEco = cmds.find((c) => c.includes('ecosystem.web.config.cjs') && c.includes('echo'));
511515
expect(webEco).toContain('myapp-green');
512516
expect(webEco).toContain('PORT: 3001');
513517

514-
const start = cmds.find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.cjs'));
518+
const start = cmds.find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.config.cjs'));
515519
expect(start).toBeDefined();
516520
// reaps any stale same-colour instance before starting
517521
expect(start).toContain('pm2 delete "myapp-green"');
@@ -522,7 +526,7 @@ describe('BackendStrategy.startApp — blue-green', () => {
522526
const executor = new FakeRemoteExecutor();
523527
await strategy.startApp!(makeCtx(executor, { deployTarget: bgTarget({ previousColor: 'blue' }) }));
524528

525-
const start = executor.getHistory().map((h) => h.command).find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.cjs'))!;
529+
const start = executor.getHistory().map((h) => h.command).find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.config.cjs'))!;
526530
expect(start).not.toContain('pm2 delete "myapp"');
527531
});
528532

@@ -532,7 +536,7 @@ describe('BackendStrategy.startApp — blue-green', () => {
532536
const ctx = makeCtx(executor, { deployTarget: bgTarget({ color: 'green', port: 3001, previousColor: null }) });
533537
await strategy.startApp!(ctx);
534538

535-
const start = executor.getHistory().map((h) => h.command).find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.cjs'))!;
539+
const start = executor.getHistory().map((h) => h.command).find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.config.cjs'))!;
536540
expect(start).not.toContain('pm2 delete "myapp"');
537541

538542
await strategy.afterTrafficSwitch!(ctx);
@@ -558,17 +562,17 @@ describe('BackendStrategy.startApp — blue-green', () => {
558562

559563
const cmds = executor.getHistory().map((h) => h.command);
560564
// web ecosystem holds only the coloured web app
561-
const webEco = cmds.find((c) => c.includes('ecosystem.web.cjs') && c.includes('echo'))!;
565+
const webEco = cmds.find((c) => c.includes('ecosystem.web.config.cjs') && c.includes('echo'))!;
562566
expect(webEco).toContain('api-green');
563567
expect(webEco).not.toContain('worker');
564568
// workers ecosystem written during start, but not reloaded yet
565-
const workersEco = cmds.find((c) => c.includes('ecosystem.workers.cjs') && c.includes('echo'))!;
569+
const workersEco = cmds.find((c) => c.includes('ecosystem.workers.config.cjs') && c.includes('echo'))!;
566570
expect(workersEco).toContain('api-worker');
567-
const start = cmds.find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.cjs'))!;
568-
expect(start).not.toContain('ecosystem.workers.cjs');
571+
const start = cmds.find((c) => c.includes('pm2 start') && c.includes('ecosystem.web.config.cjs'))!;
572+
expect(start).not.toContain('ecosystem.workers.config.cjs');
569573

570574
await strategy.afterHealthy!(ctx);
571575
const afterCmds = executor.getHistory().map((h) => h.command);
572-
expect(afterCmds.some((c) => c.includes('pm2 reload') && c.includes('ecosystem.workers.cjs'))).toBe(true);
576+
expect(afterCmds.some((c) => c.includes('pm2 reload') && c.includes('ecosystem.workers.config.cjs'))).toBe(true);
573577
});
574578
});

tests/unit/blue-green-orchestrator.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('blue-green deploy (orchestrator)', () => {
6969
const cmds = history.map((h) => h.command);
7070

7171
// green avoids interrupting a legacy uncoloured process on the blue port
72-
const webEco = cmds.find((c) => c.includes('ecosystem.web.cjs') && c.includes('echo'));
72+
const webEco = cmds.find((c) => c.includes('ecosystem.web.config.cjs') && c.includes('echo'));
7373
expect(webEco).toBeDefined();
7474
expect(webEco).toContain('app-green');
7575
expect(webEco).toContain('PORT: 3001');
@@ -101,7 +101,7 @@ describe('blue-green deploy (orchestrator)', () => {
101101
await orchestrator.deploy({ cwd: '/test', skipBuild: false });
102102

103103
const cmds = executor.getHistory().map((h) => h.command);
104-
const webEco = cmds.find((c) => c.includes('ecosystem.web.cjs') && c.includes('echo'));
104+
const webEco = cmds.find((c) => c.includes('ecosystem.web.config.cjs') && c.includes('echo'));
105105
expect(webEco).toContain('app-green');
106106
expect(webEco).toContain('PORT: 3001');
107107
expect(cmds.some((c) => c.includes('curl') && c.includes('localhost:3001/health'))).toBe(true);
@@ -195,8 +195,8 @@ describe('blue-green deploy (orchestrator)', () => {
195195
await orchestrator.deploy({ cwd: '/test', skipBuild: false });
196196

197197
const cmds = executor.getHistory().map((h) => h.command);
198-
const webStartIdx = cmds.findIndex((c) => c.includes('pm2 start') && c.includes('ecosystem.web.cjs'));
199-
const workersIdx = cmds.findIndex((c) => c.includes('ecosystem.workers.cjs') && (c.includes('pm2 reload') || c.includes('pm2 start')));
198+
const webStartIdx = cmds.findIndex((c) => c.includes('pm2 start') && c.includes('ecosystem.web.config.cjs'));
199+
const workersIdx = cmds.findIndex((c) => c.includes('ecosystem.workers.config.cjs') && (c.includes('pm2 reload') || c.includes('pm2 start')));
200200
const curlIdx = cmds.findIndex((c) => c.includes('curl') && c.includes('/health'));
201201
const caddyIdx = cmds.findIndex((c) => c.includes('systemctl reload caddy'));
202202

@@ -219,7 +219,7 @@ describe('blue-green deploy (orchestrator)', () => {
219219

220220
const commands = executor.getHistory().map((entry) => entry.command);
221221
expect(commands.some((command) => command.includes('ecosystem.config.cjs') && command.includes('pm2 start'))).toBe(true);
222-
expect(commands.some((command) => command.includes('ecosystem.web.cjs'))).toBe(false);
222+
expect(commands.some((command) => command.includes('ecosystem.web.config.cjs'))).toBe(false);
223223
expect(commands.some((command) => command.includes('deploy-state.json'))).toBe(false);
224224
});
225225
});

0 commit comments

Comments
 (0)