|
| 1 | +# One config file describes N apps; the workspace is the deploy unit |
| 2 | + |
| 3 | +A monorepo with two web apps (e.g. an Adonis API + a TanStack SSR frontend) currently needs two `shipnode.*.config.ts` files, two `shipnode deploy` invocations, and two manual `cloudflare init` runs that overwrite each other's `config.yml`. The accidental complexity scales linearly with apps: doubled env-file paths, doubled tunnel coordination, doubled rollback bookkeeping. There is no place to express "these apps share an SSH host, a base deploy path, and a Cloudflare tunnel" — so users encode it by convention and discover divergences at deploy time. |
| 4 | + |
| 5 | +`shipnode.config.ts` in 3.0 describes a **workspace** — a set of apps that share infrastructure. Apps are declared as a flat array: |
| 6 | + |
| 7 | +```ts |
| 8 | +const api = shipnode.app() |
| 9 | + .backend() |
| 10 | + .name('api') |
| 11 | + .appRoot('apps/backend') |
| 12 | + .domain('api.example.com') |
| 13 | + .port(3333) |
| 14 | + .postDeploy(async ({ exec }) => exec('node ace.js migration:run --force')); |
| 15 | + |
| 16 | +const web = shipnode.app() |
| 17 | + .backend() |
| 18 | + .name('web') |
| 19 | + .appRoot('apps/frontend') |
| 20 | + .domain('example.com') |
| 21 | + .port(3000); |
| 22 | + |
| 23 | +export default shipnode |
| 24 | + .ssh({ host: '1.2.3.4', user: 'root' }) |
| 25 | + .deployTo('/var/www/example') |
| 26 | + .nodeVersion('24') |
| 27 | + .pkgManager('pnpm') |
| 28 | + .apps([api, web]) |
| 29 | + .cloudflare({ zone: 'example.com', tunnelName: 'example', lockdownFirewall: true }) |
| 30 | + .database({ type: 'postgres', host: 'localhost', port: 5432, name: 'example', user: 'example' }) |
| 31 | + .redis({ host: 'localhost', port: 6379 }) |
| 32 | + .build(); |
| 33 | +``` |
| 34 | + |
| 35 | +The split between workspace-level and app-level config follows what is actually shared vs. independent on a single-host deploy: |
| 36 | + |
| 37 | +| Workspace | Per-app | |
| 38 | +|---|---| |
| 39 | +| `ssh`, `deployTo`, `nodeVersion`, `pkgManager`, `installCommand` | `name`, `appType` (backend/frontend), `appRoot`, `domain`, `port`, `healthCheck`, `envFile`, `hooks`, `keepReleases`, `worker()` | |
| 40 | +| `cloudflare` (one tunnel, all hostnames) | `pm2` name and options (defaults to `name`) | |
| 41 | +| `database`, `redis` (services on the box) | — | |
| 42 | +| `backup` (S3 bucket is a server-wide concern) | — | |
| 43 | + |
| 44 | +Workers stay inside an app. `pm2.apps[]` already exists as the list of processes that belong to *one* deployment unit (per [[0002-port-presence-determines-web-app]]); it is unchanged. What we add is a level above it — the workspace's `apps[]` — and the two are not the same concept. "Deployment unit" (workspace.apps[N]) vs "process" (deploymentUnit.pm2.apps[M]). Conflating them was the central confusion of the 2.x design. |
| 45 | + |
| 46 | +Releases live at `${deployTo}/${app.name}/releases/<timestamp>/`, with `${deployTo}/${app.name}/current` as the per-app atomic symlink. Each app keeps its own release history and rolls back independently. The shared `${deployTo}/shared/` directory hosts per-app `.env` files at `shared/${app.name}.env` (and a `shared/.env` alias for the app pointed to by the implicit `--app default` when there is exactly one app — see compat below). |
| 47 | + |
| 48 | +Each app's `domain` produces its own Caddy site block, and each app's `port` is the upstream. Caddy's config file is generated by enumerating `config.apps.filter(a => a.domain)`. Nothing else changes there. |
| 49 | + |
| 50 | +`cloudflare init` becomes the obvious thing: enumerate `config.apps.filter(a => a.domain && a.port)`, emit one ingress entry per app into the tunnel `config.yml`, route DNS for each hostname, and restart `cloudflared`. The tunnel name lives at the workspace level — there is exactly one per workspace. `appHostname` on `CloudflareConfig` is removed; the field encoded a single-app world view that the workspace makes obsolete. |
| 51 | + |
| 52 | +CLI commands take `--app <name>`. Without it, `deploy`, `env`, `restart`, `stop`, `logs`, and `status` operate on all apps; `deploy` runs them sequentially in declaration order and aborts on the first failure. `rollback` requires `--app` explicitly — rolling back the whole workspace in one click is almost never what someone wants, and silently composing it from per-app rollbacks would race against in-flight health checks. `cloudflare init` and `harden` stay workspace-level (no `--app` accepted). |
| 53 | + |
| 54 | +### Schema-first canonical source |
| 55 | + |
| 56 | +The 2.x config shape lives in four places that must agree: the builder's internal state, `assembleConfig`'s `withDefaults` object, the zod `ShipnodeConfigSchema`, and the `ShipnodeConfig` TypeScript interface. Adding a field means editing all four. `.aliases()` got dropped in 2.5.1 because the schema and assembly were silently out of sync — no test caught it because the test surface mirrors the same gap. |
| 57 | + |
| 58 | +In 3.0 the zod schema is the single source of truth. The TypeScript shape is derived (`type ShipnodeConfig = z.infer<typeof ShipnodeConfigSchema>`); the builder's internal accumulator is typed the same way; `assembleConfig` becomes a pure transformer from the loose input shape to the parsed canonical shape, with no manual field enumeration. A new field appears in the schema and is automatically visible everywhere. A schema-round-trip test asserts that every builder method writes a field reachable from the parsed config. |
| 59 | + |
| 60 | +### Internal layers |
| 61 | + |
| 62 | +2.x's `cli/commands/*.ts` files mix three things: argument parsing, business logic, and SSH execution. `cli/commands/cloudflare.ts` is 249 lines of API calls, YAML generation, exec shell, stdout parsing, and prompts interleaved. The 3.0 layout separates them: |
| 63 | + |
| 64 | +- `domain/` — pure logic, no I/O. Models, validation, calculations. The config types live here. Cloudflare's tunnel + ingress shape lives here too (`domain/cloudflare/`); previously it was implicit inside the CLI command. |
| 65 | +- `infrastructure/` — the I/O edges: SSH connection, cloudflared API client, PM2 exec, fs, prompts, terminal UI. One module per external thing. |
| 66 | +- `services/` — orchestrators that compose domain logic with infrastructure. `services/deploy-orchestrator.ts`, `services/cloudflare-orchestrator.ts`, etc. These are where the "happens-in-order" logic lives. |
| 67 | +- `cli/commands/*.ts` — adapters. Parse flags, call one service, format the result. Each command should be readable in one screen. `cli/commands/cloudflare.ts` goes from 249 lines to ~50. |
| 68 | + |
| 69 | +The shape isn't novel — it's hexagonal-ish — but it makes the CLI commands testable without spinning up a shell, and it gives `domain/cloudflare/` a home where the multi-hostname state can be modeled as a real object (a `Tunnel` with a list of `Ingress` entries) instead of being reconstructed from string-templated YAML on each `cloudflare init`. This is what makes the multi-hostname behavior idempotent by construction. |
| 70 | + |
| 71 | +### Rejected alternatives |
| 72 | + |
| 73 | +**Fluent `.app(name)…done()` sub-builder.** Reads nicely on the page but breaks composition: defining an app outside the workspace expression (for reuse or conditional inclusion) requires inverting the chain, and the `done()` terminator is easy to forget without a runtime check. The array form lets each app be a plain expression, typeable by `ReturnType<typeof shipnode.app>`, and lets users build the list with `if`/`map` if they want to. |
| 74 | + |
| 75 | +**Workspace meta-config (`shipnode.workspace.ts` referencing N existing `shipnode.*.config.ts`).** Preserves the existing per-app files but doubles the surface area: every shared invariant (same SSH host, same node version) has to be re-checked at runtime because nothing in the type system says the files agree. The point of the workspace is to make agreement structural. |
| 76 | + |
| 77 | +**Adding `--merge` to `cloudflare init` while keeping per-app configs.** Solves the immediate `config.yml` overwrite but does nothing about the env-file fragmentation, the doubled deploy invocations, or the lack of a single source of truth for "what runs on this server". It is the smallest possible patch and the wrong one to ship as 3.0. |
| 78 | + |
| 79 | +### Compatibility with 2.x |
| 80 | + |
| 81 | +A 2.x `shipnode.config.ts` (no `.apps([])`, app fields at the top level) loads under 3.0 by auto-wrap: `assembleConfig` detects the absence of `apps` and constructs an internal workspace with a single app whose `name` defaults to the `pm2` name (or `'app'` if none). All 2.x commands work unchanged in this shape — the same code paths run, just with `--app` implicitly selecting the lone entry. The legacy `shipnode.backend.config.ts` / `shipnode.frontend.config.ts` split a user might have built in 2.x continues to work as two independent single-app workspaces; migrating them into one workspace is a copy-paste of two builders into an `.apps([api, web])` call and is *not* required to upgrade. |
| 82 | + |
| 83 | +Two things genuinely break: |
| 84 | + |
| 85 | +1. `CloudflareConfig.appHostname` is removed. Workspaces with multiple `.domain()`-bearing apps now derive ingress entries from the apps; single-app workspaces inherit the app's `domain` automatically. A migration shim emits a warning and falls back to `apps[0].domain` if `appHostname` is still passed. |
| 86 | +2. `pm2.apps[]` cross-app collisions are no longer prevented by the deployment-name namespace (apps now have their own namespaces). Worker names within an app must still be unique; worker names across apps may collide and will be auto-prefixed with the app name in the running PM2 process list. |
| 87 | + |
| 88 | +### Out of scope for 3.0 |
| 89 | + |
| 90 | +- Multi-host workspaces. The single-VPS premise is unchanged; `ssh` stays a workspace-level singleton. |
| 91 | +- Cross-app health-check ordering beyond the sequential declaration order. |
| 92 | +- Service-discovery between apps (apps continue to reach each other via `localhost:<port>` as today). |
| 93 | +- Generated docker/compose output. shipnode remains a PM2/Caddy/cloudflared deployer. |
0 commit comments