Skip to content

Commit 3841ade

Browse files
committed
Document fleet mode: README, ADR-0007, CHANGELOG
The README gains a horizontal-scaling section with a worked config, the load-balancer contract (point your health check at /_shipnode/ready), and a prominent note on drainWait — the one number shipnode cannot infer and the difference between a zero-downtime roll and a visible outage. ADR-0007 records the design and, as importantly, its limits: why shipnode owns a readiness endpoint instead of managing a load balancer, why draining is a sentinel file rather than a config rewrite, why replicas never claim the domain, why a failed roll is a state rather than an exception, and what is deliberately absent.
1 parent e69156f commit 3841ade

3 files changed

Lines changed: 196 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to `@devalade/shipnode` will be documented here.
55
## [Unreleased]
66

77
### Added
8+
- **Fleet mode — one app on N servers behind your load balancer.** An `on` target resolving to more than one server makes the app a fleet: `shipnode deploy` rolls through its replicas a batch at a time, draining each out of rotation before touching it. Blue-green stays intra-replica, so the composition is blue-green within a replica, rolling across replicas. See [ADR-0007](docs/adr/0007-fleet-replication.md).
9+
- **Shipnode does not manage the load balancer.** You provision it (Hetzner, DigitalOcean, ALB, nginx); shipnode owns one readiness endpoint per replica, `/_shipnode/ready`, which answers `200` normally and `503` while that replica deploys. Point your LB's health check at it. No provider APIs, no credentials — SSH remains the only requirement.
10+
- **`fleet.drainWait` (default 30s) is the setting that matters.** Shipnode drains instantly; your LB only notices on its next health check, after its failure threshold. Shipnode can see neither, so you declare the wait. Below your LB's *interval × unhealthy-threshold* and the roll drops requests.
11+
- Draining is a sentinel file matched by Caddy, not a config rewrite: one `touch`, no reload, immediate, and it survives a Caddy or host restart — so a replica drained by a failed roll stays drained rather than quietly rejoining the pool.
12+
- Fleet replicas serve plain HTTP on `privateHost:fleet.port` and never claim the app's domain. Five replicas all requesting a certificate for one name would race Let's Encrypt. **TLS terminates at your load balancer**; pass-through TLS is out of scope.
13+
- `.group(name, servers)` names a set of servers; `on` accepts a server, a group, or a list of either. Every fleet member must declare `privateHost`.
14+
- A failed roll stops and reports the mixed-version fleet rather than claiming success. Updated replicas keep serving the new release, untouched ones the old, batch-mates drained but never touched go back into rotation, and **the failed replica stays drained** — out of rotation and inspectable as it died.
15+
- One release id for the whole roll, so `shipnode status` can distinguish a converged fleet from a half-rolled one.
16+
- **`.beforeFleet()` / `.afterFleet()` — hooks that run once per roll**, on the first replica and the last, at the same lifecycle points as `preDeploy`/`postDeploy`. `preDeploy` runs once *per replica*, so a migration there runs three times on a three-server fleet; `beforeFleet` is where migrations belong. `beforeFleet` lands while the old code still serves (expand), `afterFleet` once every replica is updated (contract). A roll that fails partway never reaches `afterFleet`. Deploy and `--dry-run` warn when a fleet app declares `preDeploy`.
17+
- **`placement: 'primary'` on a pm2 app** pins a process to the first server the app is declared on — crons and schedulers, where N replicas means every job fires N times. Primary is a property of the *server*, not the roll, so `deploy --on web-b` cannot promote a secondary and start a second scheduler. Rejected on a pm2 app with a port: the LB would keep routing to replicas serving nothing.
18+
- **`SHIPNODE_<ACCESSORY>_HOST` for cross-server accessories.** Docker networks are host-local, so no connection string was ever generated and the shipnode default of `localhost` silently pointed at nothing once the database moved to its own box. Every app is now handed the address of each accessory it `dependsOn``127.0.0.1` when co-located, the host's `privateHost` when not — so moving an accessory to its own server is a config change, not a code change. Declared env wins.
19+
- **`shipnode drain` / `shipnode undrain`** for manual rotation control: take a replica out without deploying it, or return one that a failed roll left behind.
20+
- **`--on <server>`** on `deploy`, `rollback`, `harden`, `status`, `logs`, and `env`.
21+
- **`shipnode status` reports fleet convergence** — each replica's release and rotation state, and an explicit warning naming which replicas are on which release when they disagree. A replica with no release, or one left drained, counts as not converged. Skipped under `--on`, where one observation proves nothing about the others.
822
- **`shipnode deploy --watch` — the development loop.** Runs one normal deploy to establish a baseline, then watches the working tree: every save is rsynced into the live release, rebuilt, reloaded, and health-probed. Each cycle is incremental — rsync receives an explicit `--files-from` list of changed paths instead of scanning the whole tree, dependencies are reinstalled only when a manifest or lockfile changes, and the health probe uses exponential backoff (100ms → 1s) rather than the deploy path's fixed 3s delay plus 2s retry gaps. Falls back to a full-tree sync when a change set is large or contains a delete, which `--files-from` cannot express. Requires `--app <name>` in a multi-app workspace; mutually exclusive with `--dry-run`.
923
- This is deliberately not a release: it patches the release that is already serving, so there is no rollback target, reload can drop in-flight requests, and local deletes do not propagate until the next full deploy. Watch mode says so on startup.
1024
- Blue-green apps reload only the colour serving traffic (via that release's `ecosystem.web.config.cjs`), leaving the idle colour untouched so it remains a valid rollback target.
@@ -17,6 +31,15 @@ All notable changes to `@devalade/shipnode` will be documented here.
1731
- The debounce has a max-wait ceiling (2s). A plain debounce resets on every event, so in a repo with a background writer — a turbo daemon, a framework's own watcher — changes would sit unemitted for as long as the writing continued, and the loop would appear hung.
1832

1933
### Fixed
34+
- **Postgres and Redis were installed on every server.** `database` and `redis` are workspace-level and had no `on` target, and the per-server config spread the whole workspace, so `shipnode setup` on a three-server workspace installed and initialised Postgres three times — same user, same database name. Both now take `on` and are stripped from every other server's scoped config.
35+
- **Cloudflare DNS clobbered other servers' domains.** `cloudflare init` iterated the whole workspace app list rather than the apps on the connected host, so whichever server ran it last repointed *every* domain at *its* tunnel — including apps living elsewhere, which then 404'd through the catch-all. DNS and ingress are now scoped to the apps actually on that host.
36+
- **Accessories could start after the apps that need them.** Servers were walked in `Object.entries(config.servers)` order and each started only its own accessories, so with the app server declared first the run was: deploy `api` → health-check `api`*then* start Postgres. The first deploy failed, and reordering the `servers` literal silently fixed it. Servers are now traversed in dependency order derived from `dependsOn`, and the cross-server warning moved from `--dry-run` into the real deploy path. A dependency cycle falls back to declaration order rather than throwing.
37+
- **One server failing aborted the rest.** `runRemoteCommandForTargets` had its `try/catch` outside the loop, so server 2 failing skipped server 3 and left the user with N-1 "Done" banners, one error, and no statement of which servers succeeded. Failures are now isolated per server, collected, summarised, and exit non-zero. `--app` also filters the target loop instead of only validating, so an unrelated server's connection failure no longer kills an app-scoped command.
38+
- **`rollback` only rolled back one server.** It went through a single-connection runner, so on a three-replica fleet it restored one server and left the other two on the bad release. It now drives the same roller a deploy uses, and its confirmation is asked once up front rather than inside the loop — where the second prompt would have arrived with half the fleet already rolled back.
39+
- **`harden` only hardened the first server**, and its seven prompts sat inside the connection, so fanning out would have asked each question once per server with earlier servers already changed. The answers are collected up front and applied to every server; it also takes `--on`.
40+
- **`shipnode deploy --dry-run --app <name>` hid cross-server dependency warnings.** It resolved them against the app-scoped config, which no longer contains the accessory's server — so the preview was quietest exactly when the dependency was remote. The real deploy path was already correct.
41+
- **The generated CI workflow assumed one server** — one key, one `known_hosts` entry. A host missing from `known_hosts` surfaced as an SSH failure partway through a roll, with earlier replicas already updated and the failing one left drained. The workflow now verifies every replica is a known host before deploying and prints the exact `ssh-keyscan` command to fix it.
42+
- **An accessory published only on loopback but consumed from another server** is now a config error rather than a runtime connection failure: no connection string can reach `127.0.0.1` on another machine.
2043
- **SSH keepalives on long-lived sessions**`deploy --watch` and `monitor` sit idle between commands, where a NAT or firewall timeout would silently drop the connection and fail the next exec. Connections now send keepalives every 15s, and `connect` starts from a fresh client so reconnecting doesn't accumulate listeners.
2144
- **`.env` symlinks survive a rebuild** — the build-output symlink logic moved to `envSymlinkCommand` and now re-runs after every hot-sync build, since a build that wipes and recreates its output directory takes the symlink with it. The initial deploy path is unchanged.
2245

README.md

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,86 @@ export default shipnode
214214
.build();
215215
```
216216

217+
### Horizontal scaling (fleets)
218+
219+
Name several servers in one `on` target and the app becomes a **fleet**: the same app on N servers, deployed by rolling through them a batch at a time.
220+
221+
```ts
222+
import { shipnode, app } from '@devalade/shipnode';
223+
224+
export default shipnode
225+
.servers({
226+
'web-a': { host: '1.2.3.4', user: 'deploy', privateHost: '10.0.0.11' },
227+
'web-b': { host: '1.2.3.5', user: 'deploy', privateHost: '10.0.0.12' },
228+
'db-1': { host: '1.2.3.6', user: 'deploy', privateHost: '10.0.0.20' },
229+
})
230+
.group('web', ['web-a', 'web-b'])
231+
.deployTo('/var/www/example')
232+
.accessories({
233+
postgres: { image: 'postgres:16', on: 'db-1', port: '0.0.0.0:5432:5432' },
234+
})
235+
.apps([
236+
app().backend().name('api').on('web').port(3333)
237+
.domain('api.example.com')
238+
.dependsOn(['postgres'])
239+
.fleet({ batch: 1, drainWait: 30 })
240+
.worker({ name: 'mailer', command: 'node dist/worker.js' })
241+
.worker({ name: 'cron', command: 'node dist/cron.js', placement: 'primary' })
242+
.beforeFleet(async ({ exec }) => { await exec('pnpm db:apply'); }),
243+
])
244+
.build();
245+
```
246+
247+
`shipnode deploy` now rolls: drain a replica → wait `drainWait` → deploy it → health-check → put it back → next.
248+
249+
**You bring the load balancer.** Shipnode never talks to it. Point your Hetzner / DigitalOcean / ALB / nginx load balancer at each replica's `privateHost:80` and set its health check to **`/_shipnode/ready`**. That endpoint answers `200` normally and `503` while shipnode is deploying that replica, which is the entire integration — no provider APIs, no credentials.
250+
251+
> **`drainWait` is the setting that matters.** Shipnode drains instantly; your load balancer only notices on its next health check, after its failure threshold. Shipnode cannot see either value, so you declare how long to wait. Set it below your LB's *interval × unhealthy-threshold* and the roll will drop requests. Verify it once by `curl`ing the LB in a loop during a deploy and confirming zero non-200s.
252+
253+
Replicas serve plain HTTP on the private network and never claim the domain — five replicas all requesting a certificate for the same name would race Let's Encrypt. **TLS terminates at your load balancer.**
254+
255+
| Setting | Default | Description |
256+
|---|---|---|
257+
| `.group(name, servers)` || Name a set of servers so `on` can target them all at once |
258+
| `privateHost` || The address the LB and other servers reach this box on. Required for every fleet member |
259+
| `.fleet({ batch })` | `1` | Replicas taken out of rotation at once |
260+
| `.fleet({ drainWait })` | `30` | Seconds to wait after draining, before touching the app |
261+
| `.fleet({ port })` | `80` | Port a replica serves on for the load balancer |
262+
| `.fleet({ readyPath })` | `/_shipnode/ready` | Readiness endpoint the LB health-checks |
263+
264+
#### Hooks that must run once
265+
266+
`preDeploy` runs **once per replica** — a migration there runs three times on a three-server fleet. Use `.beforeFleet()` (runs on the first replica, before the new code goes live anywhere) and `.afterFleet()` (runs on the last, once every replica is updated). A roll that fails partway never reaches `afterFleet`.
267+
268+
#### Workers that must run once
269+
270+
`.worker({ placement: 'primary' })` pins a process to the first server in the fleet. Use it for crons and schedulers, where N copies means every job fires N times. Queue workers competing for jobs want the default (`'all'`).
271+
272+
#### Talking to accessories on another server
273+
274+
Each app is given `SHIPNODE_<ACCESSORY>_HOST` for every accessory it `dependsOn``127.0.0.1` when co-located, the accessory server's `privateHost` when not. Read it in your connection string and moving Postgres to its own box becomes a config change:
275+
276+
```ts
277+
const url = `postgres://app:${pw}@${process.env.SHIPNODE_POSTGRES_HOST}:5432/app`;
278+
```
279+
280+
Bind the accessory somewhere reachable (`'0.0.0.0:5432:5432'`, not `'127.0.0.1:5432:5432'`) and run `shipnode harden` to open that port to the replicas that need it — and only to them.
281+
282+
#### Fleet commands
283+
284+
```bash
285+
shipnode deploy # roll every replica
286+
shipnode deploy --on web-a # deploy one replica
287+
shipnode status # per-replica release + rotation, flags skew
288+
shipnode rollback --app api # roll the rollback across the fleet
289+
shipnode drain --app api --on web-a # take one replica out of rotation
290+
shipnode undrain --app api --on web-a # put it back
291+
```
292+
293+
`shipnode status` is what tells you a roll died halfway: it compares replicas and reports which ones are on which release, and which are sitting out of rotation.
294+
295+
See [ADR-0007](docs/adr/0007-fleet-replication.md) for the design and its limits.
296+
217297
### All options
218298

219299
| Method | Default | Description |
@@ -254,16 +334,20 @@ shipnode deploy # Deploy (zero-downtime by default)
254334
shipnode deploy --dry-run # Preview without making changes
255335
shipnode deploy --skip-build # Skip local build step
256336
shipnode deploy --watch # Deploy once, then sync + reload on every save
337+
shipnode deploy --on web-a # Deploy one server (or one replica of a fleet)
257338
shipnode doctor # Check local + remote config
258339
shipnode doctor --security # Run security audit
259-
shipnode status # Show PM2 process status
340+
shipnode status # PM2 status; for a fleet, per-replica release + skew
260341
```
261342

262343
### Release management
263344

264345
```bash
265-
shipnode rollback # Roll back to the previous release
346+
shipnode rollback # Roll back to the previous release (rolls across a fleet)
266347
shipnode rollback --steps 3 # Roll back 3 releases
348+
shipnode rollback --on web-a # Roll back one replica
349+
shipnode drain --app api --on web-a # Take a replica out of rotation
350+
shipnode undrain --app api --on web-a # Put it back
267351
shipnode migrate # Migrate existing deploy to zero-downtime structure
268352
```
269353

0 commit comments

Comments
 (0)