Skip to content

Commit 99c068d

Browse files
committed
feat: support multi-process deployments (web + workers)
A backend deployment can now declare additional long-running processes alongside the web server. PM2 supervises them all under one deployment, with worker names namespace-prefixed to prevent collisions between multiple shipnode deployments on the same host. Adds .worker() to the builder, generalises pm2 config into pm2.apps[], moves the ecosystem file into each release (ADR-0001), and enriches the post-deploy health check with a pm2 jlist status assertion so workers that crash on startup don't slip past the HTTP probe.
1 parent 5b31abe commit 99c068d

31 files changed

Lines changed: 808 additions & 161 deletions

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@ All notable changes to `@devalade/shipnode` will be documented here.
44

55
## [Unreleased]
66

7+
### Added
8+
- **Workers / multi-process deployments** — a backend can now declare additional long-running processes (workers, cron consumers, queues) alongside the web server. PM2 supervises all of them under one deployment.
9+
- New `.worker({ name, command, instances?, maxMemory?, env? })` builder method appends a worker to the deployment.
10+
- New `pm2.apps` config shape: each entry has `name`, optional `command` (custom entry like `node dist/worker.js`; defaults to `<pkgManager> start`), `port` (web app only), `instances`, `maxMemory`, `env`.
11+
- The entry with a `port` is the web app; entries without are workers (see [ADR-0002](docs/adr/0002-port-presence-determines-web-app.md)).
12+
- Worker-only deployments are legal — a backend with no web app skips the HTTP health check and Caddy site config.
13+
- `restart`, `stop`, `logs` accept `--process <name>` to target a single app; without it they operate on the whole deployment via the PM2 namespace.
14+
- Worker names are automatically prefixed with the deployment namespace when registered with PM2 (e.g. `.pm2('api')` + `.worker({name: 'mailer'})` shows up in `pm2 list` as `api` and `api-mailer`). Prevents collisions between multiple shipnode deployments on the same host. Users always refer to the short name in shipnode commands.
15+
- **PM2 status check** — after every deploy, `pm2 jlist` is parsed and each declared app must be `online` with `restart_time === 0`. Catches workers that boot and crash before the HTTP health check would notice anything. Runs even on worker-only deployments.
16+
- **Per-app `env`** — each `pm2.apps` entry can set its own env vars (`{ WORKER_QUEUE: 'emails' }`); PM2 loads the shared `.env` via `env_file` and applies per-app overrides on top.
17+
18+
### Changed
19+
- **Ecosystem file is per-release**`ecosystem.config.cjs` now lives inside each release directory instead of `<remotePath>/shared/`. PM2 references it via `<remotePath>/current/ecosystem.config.cjs` so rollback restores the exact process set that was active for that release (a worker added in v2 won't crash-loop after rolling back to v1). See [ADR-0001](docs/adr/0001-per-release-ecosystem-file.md).
20+
- **Builder back-compat preserved**`.pm2(name, opts)` and `.port(n)` still work and now act as sugar on the first app. Existing `shipnode.config.ts` files don't need changes.
21+
- `shipnode config show` now lists each PM2 app with its full per-entry shape.
22+
- `shipnode status` shows every declared app, not just the one matching the deployment name.
23+
24+
### Removed
25+
- `ShipnodeConfig.backend` and the `BackendConfig` type — the port now lives on the web `pm2.apps` entry. Users of the builder/loader are unaffected; only direct consumers of the `ShipnodeConfig` TypeScript shape need to read `pm2.apps.find(a => a.port !== undefined)?.port` instead of `config.backend?.port`.
26+
- The legacy `pm2: { name, instances, maxMemory }` object shape on `ShipnodeConfig` — same migration: read `pm2.apps[0]` instead. (The legacy *input* shape is still accepted by `assembleConfig` and folded onto `apps[0]`.)
27+
28+
### Migration
29+
- **No config changes required** for existing deployments — your current `shipnode.config.ts` keeps working.
30+
- The first deploy under this version silently cleans up any pre-existing PM2 process with the deployment's name (legacy single-app deploys), so the migration is invisible.
31+
- To add a worker: chain `.worker({ name: 'mailer', command: 'node dist/worker.js' })` on your builder.
32+
733
## [2.2.0] - 2026-05-17
834

935
### Added

CONTEXT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ User-provided function that runs at a fixed point in the deployment lifecycle. `
5353
### HealthCheck
5454
A remote curl-based check that verifies the backend application is responding after a deployment. Only runs for backend apps in zero-downtime mode.
5555

56+
### Pm2Config
57+
The process-supervision section of a `ShipnodeConfig`. Contains an `apps` list — one entry per long-running process PM2 should supervise (web server, workers, etc.). Backend-only; frontend apps don't have one.
58+
59+
### Pm2App
60+
A single PM2-supervised process within a `Pm2Config`. Carries its own `name`, optional `command` (defaults to the package manager's `start` script), `instances`, `maxMemory`, `env`, and optionally a `port`. **The entry with a `port` is the web app** — it receives the HTTP health check and Caddy upstream wiring. Entries without a `port` are workers: PM2 supervises them, but shipnode doesn't curl them. At most one entry may carry a `port`; zero is legal (worker-only deployments).
61+
5662
### Caddy
5763
The reverse proxy / static file server configured per-deployment. `CaddyService` writes config to `/etc/caddy/conf.d/` and reloads the service.
5864

README.md

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,42 @@ export default shipnode
4141
.build();
4242
```
4343

44+
### Web + workers
45+
46+
A backend can run additional long-running processes alongside the web server. PM2 supervises all of them under one deployment.
47+
48+
```ts
49+
export default shipnode
50+
.backend()
51+
.ssh({ host: '1.2.3.4', user: 'deploy' })
52+
.deployTo('/var/www/myapp')
53+
.pm2('myapp', { instances: 2 })
54+
.port(3000)
55+
.worker({ name: 'mailer', command: 'node dist/worker.js' })
56+
.worker({ name: 'cron', command: 'node dist/cron.js', env: { JOB: 'cleanup' } })
57+
.build();
58+
```
59+
60+
After deploy, `shipnode status` lists every app, and `shipnode restart` / `stop` / `logs` operate on the whole deployment by default. Use `--process <name>` to target a single one (use the short name from your config — shipnode handles the PM2-side naming):
61+
62+
```bash
63+
shipnode logs --process mailer
64+
shipnode restart --process cron
65+
```
66+
67+
> **Note on PM2 naming.** PM2 has a flat global namespace, so two deployments on the same host with a `worker` entry would collide. To prevent that, shipnode prefixes worker names with the deployment namespace when registering them with PM2. A config with `.pm2('api')` + `.worker({name: 'mailer'})` shows up in `pm2 list` as `api` and `api-mailer`. You always use the short name (`mailer`) in shipnode commands.
68+
69+
A backend can also be **worker-only** (no web server, no domain) — useful for queue consumers or cron runners. Just omit `.port(...)` and `.domain(...)`:
70+
71+
```ts
72+
export default shipnode
73+
.backend()
74+
.ssh({ host: '1.2.3.4', user: 'deploy' })
75+
.deployTo('/var/www/queue-runner')
76+
.worker({ name: 'jobs', command: 'node dist/worker.js' })
77+
.build();
78+
```
79+
4480
### Frontend apps
4581

4682
```ts
@@ -62,8 +98,9 @@ export default shipnode
6298
| `.backend()` / `.frontend()` || App type |
6399
| `.ssh({ host, user, port?, identityFile? })` | port 22 | SSH connection |
64100
| `.deployTo(path)` | `/var/www/app` | Remote deploy path |
65-
| `.pm2(name, opts?)` || PM2 process name + options |
66-
| `.port(n)` | `3000` | Backend port |
101+
| `.pm2(name, opts?)` || PM2 process name + options (the web app) |
102+
| `.port(n)` | `3000` | Backend port (marks the entry as the web app) |
103+
| `.worker({ name, command, ... })` || Add a worker process supervised alongside the web app |
67104
| `.domain(d)` || Domain for Caddy config |
68105
| `.nodeVersion(v)` | `lts` | Node version (via mise) |
69106
| `.pkgManager(pm)` | auto-detected | `npm` \| `yarn` \| `pnpm` \| `bun` |
@@ -115,11 +152,14 @@ shipnode run bash # Open an interactive shell
115152
### Process management
116153

117154
```bash
118-
shipnode logs # Tail PM2 logs (last 100 lines)
119-
shipnode logs --lines 500 # Show 500 lines
120-
shipnode restart # Reload PM2 with --update-env
121-
shipnode stop # Stop the application
122-
shipnode metrics # Open PM2 monit dashboard
155+
shipnode logs # Tail PM2 logs for the whole deployment
156+
shipnode logs --process mailer # Tail logs for one app
157+
shipnode logs --lines 500 # Show 500 lines
158+
shipnode restart # Reload every app with --update-env
159+
shipnode restart --process mailer # Reload one app
160+
shipnode stop # Stop every app in the deployment
161+
shipnode stop --process mailer # Stop one app
162+
shipnode metrics # Open PM2 monit dashboard
123163
```
124164

125165
### Security & maintenance
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Per-release PM2 ecosystem file
2+
3+
The PM2 ecosystem file describes which processes constitute a deployed app (web + workers). It used to live at `/remotePath/shared/ecosystem.config.cjs` — shared across releases — which was fine when the ecosystem was effectively constant. Once apps can declare arbitrary workers, the ecosystem becomes part of the application's shape and can differ from one release to the next.
4+
5+
We write the ecosystem into `<releasePath>/ecosystem.config.cjs` instead, and PM2 commands target `<remotePath>/current/ecosystem.config.cjs` (stable across rollbacks because the `current` symlink always points at the active release). Rollback then restores the symlink *and*, transparently, the ecosystem that matches that release's code — a worker added in v2 won't crash-loop after rolling back to v1.
6+
7+
The alternative was snapshotting the shared ecosystem inside `ReleaseManager` metadata and restoring it on rollback. That works but invents a new "ecosystem snapshot" concept parallel to the existing release boundary, when the release boundary already means exactly this.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Port presence determines the web app; no `type` discriminator on `Pm2App`
2+
3+
`pm2.apps` is a uniform list of processes — web servers and workers share the same shape. The web app is identified by carrying a `port`; workers omit it. There is deliberately no `type: 'web' | 'worker'` field.
4+
5+
The discriminator would be a second source of truth that can disagree with itself: it would still be the `port` that drives the health check and Caddy upstream, so a `type: 'web'` entry without a port (or a `type: 'worker'` entry with one) would have to be rejected anyway. Encoding the same fact twice invites bugs. `assembleConfig` enforces: at most one port-bearing entry; zero is legal (worker-only deployments); a `domain` requires a web entry.
6+
7+
The cost is that "which entry is the web app?" isn't visible from a glance at one entry — you have to look at the list. We accept that in exchange for not maintaining a tagged union whose tag is redundant with its data.

src/cli/commands/cloudflare.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { runRemoteCommand } from '../runner.js';
22
import { ui } from '../ui.js';
3+
import { getWebApp } from '../../domain/pm2/apps.js';
34

45
const CF_API = 'https://api.cloudflare.com/client/v4';
56

@@ -108,8 +109,12 @@ export async function cmdCloudflareInit(
108109
}
109110

110111
// Generate config and start as systemd service
111-
const appIngress = cf.appHostname
112-
? ` - hostname: ${cf.appHostname}\n service: http://localhost:${config.backend?.port ?? 3000}`
112+
const webApp = getWebApp(config);
113+
if (cf.appHostname && !webApp) {
114+
throw new Error('cloudflare.appHostname is set but no pm2.apps entry declares a port — nothing to route HTTP to.');
115+
}
116+
const appIngress = cf.appHostname && webApp
117+
? ` - hostname: ${cf.appHostname}\n service: http://localhost:${webApp.port}`
113118
: '';
114119
const sshIngress = cf.sshHostname
115120
? ` - hostname: ${cf.sshHostname}\n service: ssh://localhost:22`
@@ -138,19 +143,23 @@ ${sshIngress}
138143

139144
// Setup firewall lockdown if requested
140145
if (cf.lockdownFirewall) {
141-
ui.info('Locking down firewall to Cloudflare IPs only...');
142-
const cfIpv4 = await cfFetch('/ips', cfApiOpts) as { ipv4_cidrs: string[] };
143-
for (const cidr of cfIpv4.ipv4_cidrs ?? []) {
146+
if (!webApp) {
147+
ui.warn('Skipping firewall lockdown: no web app (no pm2.apps entry with a port) to expose.');
148+
} else {
149+
ui.info('Locking down firewall to Cloudflare IPs only...');
150+
const cfIpv4 = await cfFetch('/ips', cfApiOpts) as { ipv4_cidrs: string[] };
151+
for (const cidr of cfIpv4.ipv4_cidrs ?? []) {
152+
await executor.exec(
153+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
154+
`$SUDO ufw allow from ${cidr} to any port ${webApp.port} 2>/dev/null || true`,
155+
);
156+
}
144157
await executor.exec(
145158
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
146-
`$SUDO ufw allow from ${cidr} to any port ${config.backend?.port ?? 3000} 2>/dev/null || true`,
159+
`$SUDO ufw deny ${webApp.port} 2>/dev/null || true`,
147160
);
161+
ui.success('Firewall locked to Cloudflare IPs');
148162
}
149-
await executor.exec(
150-
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
151-
`$SUDO ufw deny ${config.backend?.port ?? 3000} 2>/dev/null || true`,
152-
);
153-
ui.success('Firewall locked to Cloudflare IPs');
154163
}
155164

156165
// Setup Access policy if accessEmails present

src/cli/commands/config.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ export async function cmdConfigShow(cwd: string, options: { config?: string }):
2323
]);
2424

2525
if (config.pm2) {
26-
ui.section('PM2', [
27-
['name', config.pm2.name],
28-
...(config.pm2.instances !== undefined ? [['instances', String(config.pm2.instances)] as [string, string]] : []),
29-
...(config.pm2.maxMemory !== undefined ? [['maxMemory', config.pm2.maxMemory] as [string, string]] : []),
30-
]);
31-
}
32-
33-
if (config.backend) {
34-
ui.section('Backend', [
35-
['port', String(config.backend.port)],
36-
]);
26+
for (const app of config.pm2.apps) {
27+
const rows: [string, string][] = [['name', app.name]];
28+
if (app.command) rows.push(['command', app.command]);
29+
if (app.port !== undefined) rows.push(['port', String(app.port)]);
30+
if (app.instances !== undefined) rows.push(['instances', String(app.instances)]);
31+
if (app.maxMemory !== undefined) rows.push(['maxMemory', app.maxMemory]);
32+
if (app.env) {
33+
for (const [k, v] of Object.entries(app.env)) rows.push([`env.${k}`, v]);
34+
}
35+
ui.section(app.port !== undefined ? `PM2 app: ${app.name} (web)` : `PM2 app: ${app.name}`, rows);
36+
}
3737
}
3838

3939
if (config.domain) {

src/cli/commands/deploy.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { LoggingExecutor } from '../../infrastructure/ssh/logging-executor.js';
55
import { runRemoteCommand } from '../runner.js';
66
import { ui } from '../ui.js';
77
import type { ShipnodeConfig } from '../../shared/types.js';
8+
import { getDeploymentName, getWebApp } from '../../domain/pm2/apps.js';
89

910
export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBuild?: boolean; config?: string }): Promise<void> {
1011
const config = await loadConfig(cwd, options.config);
@@ -18,7 +19,7 @@ export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBu
1819
cwd,
1920
async ({ config, executor }) => {
2021
ui.banner();
21-
ui.step(`Deploying ${chalk.bold(config.pm2?.name ?? config.app)}${config.ssh.user}@${config.ssh.host}`);
22+
ui.step(`Deploying ${chalk.bold(getDeploymentName(config) ?? config.app)}${config.ssh.user}@${config.ssh.host}`);
2223

2324
const deployer = new DeployService(new LoggingExecutor(executor), config, cwd);
2425
await deployer.execute(options.skipBuild ?? false);
@@ -46,8 +47,13 @@ function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
4647
];
4748

4849
if (config.app === 'backend') {
49-
if (config.pm2?.name) serverRows.push(['PM2 name', config.pm2.name]);
50-
serverRows.push(['Port', String(config.backend?.port ?? 3000)]);
50+
const apps = config.pm2?.apps ?? [];
51+
if (apps.length) {
52+
serverRows.push(['PM2 deployment', getDeploymentName(config) ?? '']);
53+
serverRows.push(['PM2 apps', apps.map((a) => a.port !== undefined ? `${a.name}(web:${a.port})` : a.name).join(', ')]);
54+
}
55+
const web = getWebApp(config);
56+
if (web) serverRows.push(['Port', String(web.port)]);
5157
}
5258

5359
if (config.domain) serverRows.push(['Domain', config.domain]);

src/cli/commands/doctor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function cmdDoctor(cwd: string, options: { config?: string; securit
1919
);
2020
}
2121

22-
function checkLocal(config: { ssh: { host?: string; user?: string }; remotePath?: string; app: string; pm2?: { name?: string } }): void {
22+
function checkLocal(config: { ssh: { host?: string; user?: string }; remotePath?: string; app: string; pm2?: { apps: unknown[] } }): void {
2323
ui.info('Checking local configuration...');
2424

2525
const issues: string[] = [];
@@ -36,8 +36,8 @@ function checkLocal(config: { ssh: { host?: string; user?: string }; remotePath?
3636
issues.push('Remote path is not configured');
3737
}
3838

39-
if (config.app === 'backend' && !config.pm2?.name) {
40-
issues.push('PM2 app name is not configured for backend app');
39+
if (config.app === 'backend' && !config.pm2?.apps.length) {
40+
issues.push('PM2 apps are not configured for backend app');
4141
}
4242

4343
if (issues.length === 0) {

src/cli/commands/env.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { pathExists } from 'fs-extra';
33
import { resolve } from 'path';
44
import { runRemoteCommand } from '../runner.js';
55
import { ui } from '../ui.js';
6+
import { getDeploymentName } from '../../domain/pm2/apps.js';
67

78
export async function cmdEnv(
89
cwd: string,
@@ -38,21 +39,22 @@ export async function cmdEnv(
3839
ui.success('Linked shared .env to current release');
3940
}
4041

41-
if (config.app === 'backend' && config.pm2?.name) {
42+
const namespace = getDeploymentName(config);
43+
if (config.app === 'backend' && namespace) {
4244
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
4345
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
4446
const checkResult = await executor.exec(
45-
`${mise}; mise exec node@${nodeVersion} -- pm2 describe ${config.pm2.name} 2>/dev/null && echo "running" || echo "stopped"`,
47+
`${mise}; mise exec node@${nodeVersion} -- pm2 describe ${namespace} 2>/dev/null && echo "running" || echo "stopped"`,
4648
);
4749

4850
if (checkResult.stdout.includes('running')) {
49-
ui.info('Restarting app to reload environment variables...');
51+
ui.info('Reloading deployment to pick up environment variables...');
5052
await executor.exec(
51-
`${mise}; mise exec node@${nodeVersion} -- pm2 reload ${config.pm2.name} --update-env`,
53+
`${mise}; mise exec node@${nodeVersion} -- pm2 reload ${namespace} --update-env`,
5254
);
53-
ui.success('App restarted with new environment variables');
55+
ui.success('Deployment reloaded with new environment variables');
5456
} else {
55-
ui.warn('App not running. Variables will be loaded on next deploy.');
57+
ui.warn('Deployment not running. Variables will be loaded on next deploy.');
5658
}
5759
}
5860
},

0 commit comments

Comments
 (0)