Skip to content

Commit e99ecdb

Browse files
committed
fix: use uncommon default blue-green port
1 parent 5f842bc commit e99ecdb

9 files changed

Lines changed: 32 additions & 17 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ export default shipnode
468468

469469
Backends with a domain and a PM2 web port use blue-green releases automatically. Shipnode starts the new web process on the idle port, checks it, and reloads Caddy only after it is healthy. Apps without a domain and worker-only apps keep the PM2 recreate path. Use `.noZeroDowntime()` to opt out explicitly; raw configuration may set `zeroDowntime: false`.
470470

471-
Blue-green keeps the previous and current web processes resident, so budget about **2× the web process memory**. On an eligible Caddy backend, `.zeroDowntime(altPort?)` remains available to force the mode and choose the alternate port (default: web port + 1).
471+
Blue-green keeps the previous and current web processes resident, so budget about **2× the web process memory**. On an eligible Caddy backend, `.zeroDowntime(altPort?)` remains available to force the mode and choose the alternate port. By default, Shipnode uses a less commonly occupied port offset by 10,000 (`3000 → 13000`); near the top of the TCP range it subtracts 10,000 instead.
472472

473473
Every app still uses a Capistrano-style release structure:
474474

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Backend web apps behind Caddy (a domain plus a PM2 web port) use blue-green rele
44

55
## Mechanism
66

7-
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`.
7+
Two ports, named **blue** and **green**. Blue is the web app's configured `port`; green is `altPort`. By default, green uses a less commonly occupied port offset by 10,000 (`3000 → 13000`); for blue ports above 55535, Shipnode subtracts 10,000 to remain within the TCP port range. 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`.
88

99
Each deploy:
1010

src/config/builder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export class ShipnodeBuilder {
134134

135135
/**
136136
* Enable zero-downtime (blue-green) releases for the web app. Optional
137-
* `altPort` sets the green port (defaults to web port + 1).
137+
* `altPort` sets the green port (defaults to an uncommon port offset by 10,000).
138138
*/
139139
zeroDowntime(altPort?: number): this {
140140
this.config.zeroDowntime = true;
@@ -363,7 +363,7 @@ export class ShipnodeAppBuilder {
363363

364364
/**
365365
* Enable zero-downtime (blue-green) releases for this web app. Optional
366-
* `altPort` sets the green port (defaults to web port + 1).
366+
* `altPort` sets the green port (defaults to an uncommon port offset by 10,000).
367367
*/
368368
zeroDowntime(altPort?: number): this {
369369
this.state.zeroDowntime = true;

src/domain/deploy/blue-green.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,20 @@ export function portFor(color: DeployColor, state: DeployState): number {
4444
return color === 'blue' ? state.bluePort : state.greenPort;
4545
}
4646

47-
/** The green port: explicit `altPort`, else web port + 1. */
47+
const ALT_PORT_OFFSET = 10_000;
48+
const MAX_TCP_PORT = 65_535;
49+
50+
/**
51+
* Resolve the green port.
52+
*
53+
* An explicit `altPort` wins. Otherwise the default is 10,000 above the web
54+
* port, or 10,000 below it near the top of the TCP port range.
55+
*/
4856
export function resolveAltPort(webPort: number, altPort?: number): number {
49-
return altPort ?? webPort + 1;
57+
if (altPort !== undefined) return altPort;
58+
return webPort <= MAX_TCP_PORT - ALT_PORT_OFFSET
59+
? webPort + ALT_PORT_OFFSET
60+
: webPort - ALT_PORT_OFFSET;
5061
}
5162

5263
/** PM2 process name for the web app of a given colour (`api-blue`, `api-green`). */

src/shared/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,8 @@ export interface ShipnodeApp {
170170
zeroDowntime: boolean;
171171
/**
172172
* The second port used by blue-green. Blue = the web app's port, green =
173-
* `altPort` (defaults to web port + 1). Both colours may be resident at once,
174-
* so this port must be free on the host.
173+
* `altPort` (defaults to an uncommon port offset by 10,000). Both colours may
174+
* be resident at once, so this port must be free on the host.
175175
*/
176176
altPort?: number;
177177
sharedDirs?: string[];

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,13 @@ describe('blue-green deploy (orchestrator)', () => {
7272
const webEco = cmds.find((c) => c.includes('ecosystem.web.config.cjs') && c.includes('echo'));
7373
expect(webEco).toBeDefined();
7474
expect(webEco).toContain('app-green');
75-
expect(webEco).toContain('PORT: 3001');
75+
expect(webEco).toContain('PORT: 13000');
7676

77-
const curl = cmds.find((c) => c.includes('curl') && c.includes('localhost:3001/health'));
77+
const curl = cmds.find((c) => c.includes('curl') && c.includes('localhost:13000/health'));
7878
expect(curl).toBeDefined();
7979

80-
// Caddy flipped to port 3000 and reloaded, then state persisted — in that order
81-
const caddyIdx = cmds.findIndex((c) => c.includes('reverse_proxy localhost:3001') && c.includes('tee'));
80+
// Caddy flipped to the green port and reloaded, then state persisted — in that order
81+
const caddyIdx = cmds.findIndex((c) => c.includes('reverse_proxy localhost:13000') && c.includes('tee'));
8282
const reloadIdx = cmds.findIndex((c) => c.includes('systemctl reload caddy'));
8383
const stateIdx = cmds.findIndex((c) => c.includes('deploy-state.json') && c.includes('base64 -d'));
8484
const cleanupIdx = cmds.findIndex((c) => c.includes('pm2 delete "app"'));

tests/unit/blue-green.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@ describe('blue-green helpers', () => {
2323
expect(portFor('green', state)).toBe(3001);
2424
});
2525

26-
it('resolveAltPort defaults to web port + 1', () => {
27-
expect(resolveAltPort(3000)).toBe(3001);
26+
it('resolveAltPort defaults to an uncommon port 10,000 above the web port', () => {
27+
expect(resolveAltPort(3000)).toBe(13000);
2828
expect(resolveAltPort(3000, 4000)).toBe(4000);
2929
});
3030

31+
it('resolveAltPort stays within the valid TCP port range', () => {
32+
expect(resolveAltPort(60_000)).toBe(50_000);
33+
});
34+
3135
it('coloredWebName suffixes the pm2 name', () => {
3236
// web app name equals namespace -> unprefixed, then coloured
3337
expect(coloredWebName('api', 'api', 'blue')).toBe('api-blue');

tests/unit/multi-app-orchestrator.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('DeployOrchestrator — multi-app', () => {
3232
// lock
3333
.when((cmd) => cmd.includes('deploy.lock'), { stdout: 'OK', exitCode: 0 })
3434
// health check on the backend
35-
.when((cmd) => cmd.includes('curl') && cmd.includes('localhost:3001'), { stdout: '200 42', exitCode: 0 })
35+
.when((cmd) => cmd.includes('curl') && cmd.includes('localhost:13000'), { stdout: '200 42', exitCode: 0 })
3636
// pm2 jlist — both apps must be online
3737
.when((cmd) => cmd.includes('pm2 jlist'), {
3838
stdout: JSON.stringify([
@@ -96,7 +96,7 @@ describe('DeployOrchestrator — multi-app', () => {
9696
expect(webMkdir.length).toBeGreaterThan(0);
9797

9898
// Health check ran for the backend (frontend has none)
99-
const health = history.filter((h) => h.command.includes('curl') && h.command.includes('localhost:3001'));
99+
const health = history.filter((h) => h.command.includes('curl') && h.command.includes('localhost:13000'));
100100
expect(health.length).toBeGreaterThan(0);
101101
});
102102
});

website/src/content/docs/docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Registry passwords are read from environment variables on the remote host. If `R
123123
| `.postDeploy(fn)` | Run a function on the server after a healthy release. |
124124
| `.build()` | Required terminal call. Returns the resolved config. |
125125

126-
Backend apps with both `.domain(...)` and a PM2 web `.port(...)` use blue-green deploys by default. They temporarily need about 2× the web process memory while both colours are resident. Backends without a domain and worker-only apps use PM2 recreate. Raw configs can opt out with `zeroDowntime: false`.
126+
Backend apps with both `.domain(...)` and a PM2 web `.port(...)` use blue-green deploys by default. The alternate port defaults to a less commonly occupied value offset by 10,000 (`3000 → 13000`), with a subtraction near the top of the TCP range. They temporarily need about 2× the web process memory while both colours are resident. Backends without a domain and worker-only apps use PM2 recreate. Raw configs can opt out with `zeroDowntime: false`.
127127

128128
## Aliases
129129

0 commit comments

Comments
 (0)