All notable changes to @devalade/shipnode will be documented here.
- Fleet mode — one app on N servers behind your load balancer. An
ontarget resolving to more than one server makes the app a fleet:shipnode deployrolls 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.- Shipnode does not manage the load balancer. You provision it (Hetzner, DigitalOcean, ALB, nginx); shipnode owns one readiness endpoint per replica,
/_shipnode/ready, which answers200normally and503while that replica deploys. Point your LB's health check at it. No provider APIs, no credentials — SSH remains the only requirement. 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.- 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. - Fleet replicas serve plain HTTP and never request a certificate — five replicas asking for one name would race Let's Encrypt. TLS terminates at your load balancer; pass-through TLS is out of scope. Each replica answers on both its
privateHost(which the LB's health check dials) and the app's domain (which the LB forwards from the client), so give a fleet app a.domain(). .group(name, servers)names a set of servers;onaccepts a server, a group, or a list of either. Every fleet member must declareprivateHost.- 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.
- One release id for the whole roll, so
shipnode statuscan distinguish a converged fleet from a half-rolled one.
- Shipnode does not manage the load balancer. You provision it (Hetzner, DigitalOcean, ALB, nginx); shipnode owns one readiness endpoint per replica,
.beforeFleet()/.afterFleet()— hooks that run once per roll, on the first replica and the last, at the same lifecycle points aspreDeploy/postDeploy.preDeployruns once per replica, so a migration there runs three times on a three-server fleet;beforeFleetis where migrations belong.beforeFleetlands while the old code still serves (expand),afterFleetonce every replica is updated (contract). A roll that fails partway never reachesafterFleet. Deploy and--dry-runwarn when a fleet app declarespreDeploy.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, sodeploy --on web-bcannot 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.SHIPNODE_<ACCESSORY>_HOSTfor cross-server accessories. Docker networks are host-local, so no connection string was ever generated and the shipnode default oflocalhostsilently pointed at nothing once the database moved to its own box. Every app is now handed the address of each accessory itdependsOn—127.0.0.1when co-located, the host'sprivateHostwhen not — so moving an accessory to its own server is a config change, not a code change. Declared env wins.shipnode drain/shipnode undrainfor manual rotation control: take a replica out without deploying it, or return one that a failed roll left behind.--on <server>ondeploy,rollback,setup,harden,status,logs, andenv.shipnode statusreports 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.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-fromlist 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-fromcannot express. Requires--app <name>in a multi-app workspace; mutually exclusive with--dry-run.- 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.
- 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. - The deploy lock is held for each cycle, so a concurrent
shipnode deploycan never interleave with a sync — it is skipped with a notice instead. --build <remote|local|none>controls where each cycle builds.remote(backend default) builds on the server.localbuilds here (fromappRootin a monorepo) and ships the artifact.none(implied by--skip-build) only syncs and reloads, which is what pairs with a framework's own watch mode. Projects that build locally and upload the bundle — Nitro/TanStack Start/Nuxt apps deployed with--skip-build— needlocalornone; onremotetheir.output/is ignored and the loop would ship source the app never runs.- The watcher is suppressed while a build shipnode runs is writing. Ignoring build directories is not sufficient: a TanStack Start/Nitro build regenerates files inside the source tree (
routeTree.gen.ts) and drops temp files at the repo root, which are indistinguishable from a developer's edit by path alone. Left unguarded this feeds each cycle back into itself — observed in the wild as apm2 reloadof the live process every ~8 seconds. Gating on when we build fixes it for any framework's codegen. - Build output is watched only when shipnode is not the thing writing it (
none). Underlocalthe cycle runs the build itself, so watching its output would feed those writes back in and rebuild forever; that mode instead syncs by full-tree rsync, which detects the fresh artifact without the watcher reporting it. - The watcher reads
.shipnodeignoreand skips those directories, so a build cache like.nitro/no longer costs a full lock/sync/reload/probe cycle to transfer nothing. - Ctrl-C during a cycle releases the deploy lock instead of leaking it. Exiting straight from the signal handler skipped the
finallythat releases it, leaving a lock no process owned and blocking every later deploy until someone ranshipnode unlock. A second Ctrl-C still exits immediately. - 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.
- Accessory firewall rules restricted nothing. Two faults, both found only by running against a real firewall. The generated ufw comment contained an apostrophe, which makes ufw reject the entire rule with
ERROR: Invalid syntaxwhileufw --force enablestill succeeds — so the firewall came up with the hole never opened. And Docker publishes container ports past ufw entirely: its own FORWARD rules are consulted first, soufw allow from <replica> to any port 5432was accepted, appeared inufw status, and left the database reachable from every host that could route to the server.hardennow writes matchingDOCKER-USERrules — the chain Docker guarantees to consult first — and sanitises comments. Note that an accessory is exposed untilhardenruns. shipnode setupfailed at PM2 startup on the documented first run. Deploy-user commands ran from root's home:sudoinherits the caller's working directory, setup runs over SSH as root, and/rootis mode 700 — so Node'sspawn()returned EACCES and the error named the node binary rather than the cause. Commands now run withsudo -Hfrom the target user's home.shipnode setup --no-deploy-userwas silently ignored. Commander turns--no-deploy-userintodeployUser: false, notnoDeployUser: true, so the flag read as undefined and the user was created anyway.- Caddy was never installed on fleet replicas. Both install gates tested
app.domain, and a fleet replica deliberately has none — but it still needs Caddy for the readiness endpoint. The first deploy died writing its site file into a directory that was never created. - Caddy sites were written but never reloaded. Only the blue-green path called
reload, and it does so for its own colour flip — so a frontend, a recreate backend, and every fleet replica wrote a site file that never took effect until something else reloaded Caddy. - The health check demanded primary-pinned processes on every replica. A
placement: 'primary'worker is absent from secondary replicas by design, so placement working correctly is what failed the health check there. - Postgres and Redis were installed on every server.
databaseandredisare workspace-level and had noontarget, and the per-server config spread the whole workspace, soshipnode setupon a three-server workspace installed and initialised Postgres three times — same user, same database name. Both now takeonand are stripped from every other server's scoped config. - Cloudflare DNS clobbered other servers' domains.
cloudflare inititerated 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. - 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: deployapi→ health-checkapi→ then start Postgres. The first deploy failed, and reordering theserversliteral silently fixed it. Servers are now traversed in dependency order derived fromdependsOn, and the cross-server warning moved from--dry-runinto the real deploy path. A dependency cycle falls back to declaration order rather than throwing. - One server failing aborted the rest.
runRemoteCommandForTargetshad itstry/catchoutside 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.--appalso filters the target loop instead of only validating, so an unrelated server's connection failure no longer kills an app-scoped command. rollbackonly 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.hardenonly 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.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.- The generated CI workflow assumed one server — one key, one
known_hostsentry. A host missing fromknown_hostssurfaced 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 exactssh-keyscancommand to fix it. - 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.1on another machine. - SSH keepalives on long-lived sessions —
deploy --watchandmonitorsit 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, andconnectstarts from a fresh client so reconnecting doesn't accumulate listeners. .envsymlinks survive a rebuild — the build-output symlink logic moved toenvSymlinkCommandand 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.
- Blue-green (zero-downtime) releases — opt a backend web app in with
.zeroDowntime(altPort?). The new release boots on the idle colour's port while the old colour keeps serving; the health check runs against the new colour, and only on success does Caddy's upstream flip via a gracefulsystemctl reload caddy(drains in-flight requests, drops nothing). A failed health check leaves the old colour serving untouched — zero user impact on a bad release. Rollback becomes an instant Caddy flip back to the still-running previous colour (no restart). Off by default; the recreate path is unchanged. Blue = the web app'sport, green =altPort(defaultport + 1). Web app runs ~2× (both colours resident between deploys); workers stay a single reloaded set. See ADR-0005.
- Blue-green workers wait for health — workers reload only after the new colour passes health (and before the Caddy flip), so a bad release no longer leaves workers on new code while traffic stays on the old colour.
- Failed deploys revert
currentand record history — on failure after the symlink switch,currentis restored to the previous release (when one exists) and astatus: 'failed'entry is written toreleases.json. - Atomic deploy lock — lock acquisition uses
mkdir(create-or-fail) instead of a TOCTOU file check. Path is still.shipnode/deploy.lock(now a directory);unlock/ monitor handle both directory and legacy file shapes.
First real production run of 3.0 surfaced a batch of bugs — all fixed — plus one new feature (incremental restic backups to Cloudflare R2 / any S3-compatible store).
- Incremental backup strategy via restic — new
BackupConfig.strategy: 'snapshot' | 'restic'(default'snapshot'for back-compat). The restic path streamspg_dumpstraight into a restic repository on S3-compatible storage (Cloudflare R2 in practice — sets3Endpointtohttps://<account-id>.r2.cloudflarestorage.com), giving block-level dedup, client-side encryption, and 1–5% incremental daily deltas.setupreadsAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYfrom the local env, generates aRESTIC_PASSWORDif absent (and prints it — save it), installs restic via apt, writes/etc/shipnode/backup.env(root:root 600) + script + systemd unit+timer.backup rungoes throughsystemctl start --waitso theEnvironmentFileis honored (a bare script exec wouldn't source it under a non-root deploy user).backup listusesrestic snapshots --compactfor the restic strategy.- New
backup restore [snapshot]command — extracts a snapshot (orlatest) to--target <path>with--tag/--hostfilters. Files are extracted, never applied — the actual recovery (psql -f db.sql,rsync -a shared/) stays a manual step so a bad restore can't silently trash production. - Retention knobs:
keepDaily(default 7),keepWeekly(4),keepMonthly(6), enforced byrestic forget --prune.
shipnode user add <name> [--key <path>] [--sudo] [--no-sync]— write/update.shipnode/users.ymland sync the user to the server in one step, removing the manual YAML editing thatuser syncpreviously required.setupbootstraps adeployuser — first-run setup creates adeployuser (sudo, keyed off${ssh.identityFile}.pub) withNOPASSWD:ALLsudo, registers it in.shipnode/users.yml, and chownsremotePathto it. Opt out with--no-deploy-user. Downstreamhardenanddeployruns land on a non-root account instead of root. Installs mise/node/pm2 into the deploy user's home sopm2-<user>.servicematches.pm2 startupruns as root but targets the deploy user;pm2 saveruns as deploy sodump.pm2is the one systemd resurrects at boot.- PM2 boot-resurrection section in
harden— verifiespm2-${ssh.user}.serviceis installed, offers to refresh its dump (pm2 save), and offers to disable stalepm2-*.serviceunits left over from a prior root-scoped setup after switchingssh.user. config show --app <name>— filter the resolved config to a single app in a multi-app workspace.
cloudflare inituses the Cloudflare REST API for tunnel creation. Previously relied oncloudflared tunnel createandcloudflared tunnel route dns, both of which require the browser-basedcloudflared tunnel loginflow (writes~/.cloudflared/cert.pem) — not scriptable.CloudflareApigainsgetAccountId,findTunnel,createTunnel, andupsertDnsRecord; the orchestrator finds-or-creates the tunnel via API with a caller-generated secret, writes the credentials JSON at/etc/cloudflared/<id>.json, upserts CNAME records pointing at<id>.cfargotunnel.com, and enables/restarts the systemd unit. Reusing a tunnel by name now requires its credentials file to already be on the host (Cloudflare doesn't echo the secret back on GET) — error message points at the fix.cloudflaredinstall falls back to the upstream.debfrom GitHub whenpkg.cloudflare.comhas no package for the current codename (fresh Ubuntu releases like 26.04/resolutelag behind). Stale apt sources entry is cleaned up on fallback soapt updatestops erroring.config show/deploy --dry-runrender every app in the workspace instead of justapps[0].config showgained--app <name>to narrow. Dry-run splits output into a workspace header + per-app plan block with namespace-prefixed PM2 names so the preview matches what actually ships.shipnode --versionnow reads frompackage.jsonat runtime instead of a hardcoded'2.0.0'string.
asUserproduced broken-u deploy bash …under root SSH. TheSUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; $SUDO -u …idiom reduced to a bare-ucommand when EUID was 0. Switched to unconditionalsudo -u(transparent under root, no password prompt).pm2 startuptargeted an invalid shim.~/.local/share/mise/shims/pm2fails with "not a valid shim" because mise only shims tools it manages itself, not npm-installed globals. Resolve pm2's real path viamise exec node@X -- which pm2under the deploy user, then invoke it withenv PATH=<node-bin-dir>:$PATH.- Bootstrapped
deployuser needed NOPASSWD sudo.syncUsersput the user in thesudogroup but sudo still prompted for a password, soharden/deployruns as deploy would hang. Setup now drops a/etc/sudoers.d/shipnode-deployNOPASSWD file right after user creation. envcommand silently uploaded nothing for nestedenvFilepaths. Forapps/backend/.env.productionin a monorepo,mkdir -p ${appPath}/shareddidn't createshared/apps/backend/, so theecho | base64 -d > ${sharedEnv}redirect failed silently. The CLI still printed "Uploaded to …" (executor.exec doesn't check exit codes), and the symlink was created pointing at a non-existent file — the next deploy broke with an obscure./.envsourcing error. Now usesexecOrThrowon each step and mkdirs the actual parent ofsharedEnvviadirname.CaddyServicewrote/etc/caddy/conf.d/<app>.caddyand reloaded Caddy without sudo. Worked when SSH user was root, broke as the deploy user withPermission denied. Both the write (via| sudo tee) and the reload (viasudo systemctl) go through sudo now.HealthCheckServicebuilt PM2 names from the workspace-leveldeploymentName(alwaysapps[0]'s namespace), so in a multi-app workspace it looked up e.g.biormin-biormin-frontendwhen PM2 actually runsbiormin-frontendunder its own namespace. Every app's check now derives the namespace from that app.- Restic env cache missed across runs. Restic warned
neither $XDG_CACHE_HOME nor $HOME are definedunder systemd's minimal env and re-fetched the R2 index each daily run.HOME='/root'is now injected via the backup env file so/root/.cache/resticpersists.
- The restic strategy is opt-in — existing snapshot backups keep working unchanged. Migrate by setting
strategy: 'restic'in the.backup(...)block, exporting AWS credentials, and re-runningshipnode backup setup.
- Multi-app workspaces — a single
shipnode.config.tscan now declare multiple applications deployed to the same server, each with its own domain, PM2 process set, health check, env file, build steps, and hooks:- New
shipnode.app()(and the standaloneapp()factory exported from the package) returns a per-app sub-builder with the per-app methods (.backend()/.frontend(),.name(),.appRoot(),.domain(),.port(),.pm2(),.worker(),.envFile(),.healthCheck(),.preDeploy()/.postDeploy(), etc.). - New
.apps([api, web])builder method on the workspace builder to compose multiple apps into one deployment. - Each app gets its own release directory (
<remotePath>/<app-name>/releases/<ts>/), own Caddy site block, own PM2 ecosystem, and own lock file. - Orchestrator iterates over all apps, selecting the right strategy per-app (backend/frontend).
- New
--app <name>CLI flag — target a single app in a multi-app workspace (shipnode deploy --app api,shipnode logs --app web). Commands without--appapply to all apps.rollbackrequires--app.getActiveApp(config, name?)workspace helper — selects the right app by name or returnsapps[0]when called without a name.domain/cloudflare/domain model —Tunnelclass with typedIngressentries,toYaml()/fromYaml()serialization, and sorted hostname output for stable, diffable configs.- Multi-hostname tunnel —
cloudflare initnow enumerates all workspace apps withdomain+ web port and creates one ingress entry per app, with automatic DNS routing.
- Config shape split: workspace-level vs. per-app fields. Workspace-level (
remotePath,ssh,pkgManager,aliases,nodeVersion, etc.) stays on the root config. Per-app fields (domain,pm2,healthCheck,envFile,keepReleases,buildDir,appRoot,sharedDirs,sharedFiles,hooks) moved intoapps[].- Legacy top-level input fields are still accepted and synthesized onto
apps[0]viaz.preprocess— backward compatible.
- Legacy top-level input fields are still accepted and synthesized onto
- Schema-based assembly —
assembleConfigno longer enumerates every field manually. The zod schema is the single source of truth; assembly normalizes legacy input and callsShipnodeConfigSchema.parse(). Prevents drift between builder, schema, and assembly (fixes the pattern that dropped.aliases()in 2.5.1). BuilderStateis now a standalone type with workspace-level and legacy input fields, no longer derives fromShipnodeConfig(which no longer has those legacy mirrors).- CLI commands are now thin adapters —
cli/commands/files are under 80 lines (cloudflare went from 249→49). Business logic extracted toservices/*-orchestrator.tsand I/O toinfrastructure/. cloudflare initrewritten — uses theTunneldomain model, iterates overconfig.appsfor ingress entries instead of a singleappHostname.
- Legacy top-level mirrors from
ShipnodeConfigTypeScript shape:config.app,config.pm2,config.domain,config.healthCheck,config.envFile,config.keepReleases,config.buildDir,config.appRoot,config.sharedDirs,config.sharedFiles,config.hooks— read fromconfig.apps[i]instead. CloudflareConfig.appHostname— use per-appdomaininstead. Ingress entries are now derived automatically from apps withdomain+ web port.
HooksConfigSchemausesz.custom<HookFn>instead ofz.function()— preserves reference equality soconfig.hooks.postDeploy === userFnis true after parse.- Schema-coverage test (
tests/unit/builder.test.ts) — every builder setter is exercised in a round-trip; prevents future drift between builder, schema, and assembly. src/infrastructure/cloudflare/api.ts— Cloudflare API client extracted from CLI command.src/infrastructure/provisioning/commands.ts— database/Redis setup command builders extracted from setup command.src/infrastructure/provisioning/security.ts— SSH/UFW/fail2ban command builders extracted from harden command.tests/unit/cloudflare.tunnel.test.ts—Tunnel.fromYaml().addIngress(...).toYaml()round-trip tests.
.aliases(map)is no longer silently dropped at config assembly. The builder stored the map on its internal state, but the zod schema didn't declare analiasesfield andassembleConfigdidn't propagate it into the parsed object — soshipnode run <name>looked up an empty map and fell through every alias as a raw command. The schema now declaresaliases?: Record<string, string>and the assembler forwards it. A regression test intests/unit/assembly.test.tscovers the round-trip.
--config <relative-path>now resolves against the user's cwd, not against shipnode's install dir. Previouslyshipnode deploy --config ./shipnode.frontend.config.tsthrewCannot find module './shipnode.frontend.config.ts'because jiti's anchor is shipnode's own loader file insidedist/. Affected anyone using per-app configs in monorepos. Absolute paths kept working and are unchanged.
- Env vars now actually reach the app under PM2 7.x. PM2's
env_fileoption silently failed to inject variables in 7.0.x, so AdonisJS / NestJS / any framework that re-validates env at boot crashed with "Missing environment variable" after deploy. Each PM2 app is now started asbash -c "set -a && . <shared-env> && set +a && exec <original-command>"; theenv_fileline is gone from the generated ecosystem. Secrets stay in the chmod-600 shared env file — they're not duplicated intoecosystem.config.cjs. See ADR-0003. shipnode envupload now matches the PM2 ecosystem reference. Upload was hardcoded toshared/.env, while the ecosystem referencedshared/${envFile}. If you set.envFile('.env.production')the names diverged and PM2 couldn't find the file. Upload now writes toshared/<envFile>; ashared/.envalias is also maintained so external scripts and the workDir-relative. ./.envkeep working..envis auto-sourced before install, build, andpreDeploy/postDeployhooks. Private-registry tokens referenced via${VAR}in.npmrcnow resolve on the remote, and framework CLIs invoked from hooks (node ace.js migration:run,nest start --watch, etc.) see the same env vars the running app will. No more crafting bespokeinstallCommand: 'set -a && ...'snippets.
-
.appRoot(path)builder method — declare a monorepo's app directory (e.g.apps/backend). Used for three things:- Symlinks the shared
.envinto<appRoot>/buildand<appRoot>/distso frameworks like AdonisJS (whose env loader reads.envfrom the compiled app root) find it. - PM2 launches the process with
cwd: <remotePath>/current/<appRoot>—pnpm startnow reads<appRoot>/package.json's start script instead of forcing the workspace root to know aboutapps/backend/build/bin/server.js. preDeploy/postDeployhooks run from<workDir>/<appRoot>—await exec('node build/ace.js migration:run')works without path duplication.
Install and build still run from the workspace root (so workspaces/turbo/nx behave normally). When unset, shipnode also auto-scans common monorepo layouts (
apps/*/build,packages/*/build, plus single-appbuild/distat the repo root) for the env symlink only. - Symlinks the shared
- 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.
- New
.worker({ name, command, instances?, maxMemory?, env? })builder method appends a worker to the deployment. - New
pm2.appsconfig shape: each entry hasname, optionalcommand(custom entry likenode dist/worker.js; defaults to<pkgManager> start),port(web app only),instances,maxMemory,env. - The entry with a
portis the web app; entries without are workers (see ADR-0002). - Worker-only deployments are legal — a backend with no web app skips the HTTP health check and Caddy site config.
restart,stop,logsaccept--process <name>to target a single app; without it they operate on the whole deployment via the PM2 namespace.- Worker names are automatically prefixed with the deployment namespace when registered with PM2 (e.g.
.pm2('api')+.worker({name: 'mailer'})shows up inpm2 listasapiandapi-mailer). Prevents collisions between multiple shipnode deployments on the same host. Users always refer to the short name in shipnode commands.
- New
- PM2 status check — after every deploy,
pm2 jlistis parsed and each declared app must beonlinewithrestart_time === 0. Catches workers that boot and crash before the HTTP health check would notice anything. Runs even on worker-only deployments. - Per-app
env— eachpm2.appsentry can set its own env vars ({ WORKER_QUEUE: 'emails' }); PM2 loads the shared.envviaenv_fileand applies per-app overrides on top.
- Builds no longer fail because devDependencies are missing. The default install commands for
npm,yarn, andbunno longer pass--production/--frozen-lockfile --production— they now install everything so the subsequent build step has access totsc,vite,tsup, etc. (pnpm was already fixed for this in v2.0.13; this completes the same fix for the other package managers.)
- README: new "Multiple environments" section showing two patterns for staging/production splits — separate
shipnode.<env>.config.tsfiles driven by--config <path>, or a single config file switched onSHIPNODE_ENV. No new CLI surface; both patterns use the existing--configflag that every command already accepts.
- Two ways to override the server-side install command when you need flags the default doesn't carry (e.g.
'npm ci --legacy-peer-deps'):.installCommand(cmd)— standalone builder method..pkgManager(pm, { installCommand: cmd })— second-arg form, useful when you're already pinning the package manager. Both write to the same field. Override applies to both the initial install and the post-symlink relink;--prefer-offlineis not appended to overridden commands.
- Ecosystem file is per-release —
ecosystem.config.cjsnow lives inside each release directory instead of<remotePath>/shared/. PM2 references it via<remotePath>/current/ecosystem.config.cjsso 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. - Builder back-compat preserved —
.pm2(name, opts)and.port(n)still work and now act as sugar on the first app. Existingshipnode.config.tsfiles don't need changes. shipnode config shownow lists each PM2 app with its full per-entry shape.shipnode statusshows every declared app, not just the one matching the deployment name.
ShipnodeConfig.backendand theBackendConfigtype — the port now lives on the webpm2.appsentry. Users of the builder/loader are unaffected; only direct consumers of theShipnodeConfigTypeScript shape need to readpm2.apps.find(a => a.port !== undefined)?.portinstead ofconfig.backend?.port.- The legacy
pm2: { name, instances, maxMemory }object shape onShipnodeConfig— same migration: readpm2.apps[0]instead. (The legacy input shape is still accepted byassembleConfigand folded ontoapps[0].)
- No config changes required for existing deployments — your current
shipnode.config.tskeeps working. - 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.
- To add a worker: chain
.worker({ name: 'mailer', command: 'node dist/worker.js' })on your builder.
shipnode run <alias>— named shortcuts for remote commands, defined via.aliases(map)inshipnode.config.ts. Extra args after the alias name are appended to the expanded command. Unknown names fall through as raw strings.
- Deploy hooks (
.preDeploy()/.postDeploy()) now correctly set up the mise PATH andcdinto the release directory before running commands — previouslypnpm,npx, etc. were not found on the remote server - Hook
exec()now throws on non-zero exit, aborting the deploy on failure — previously migration failures were silently swallowed - Hook command output now streams live to the terminal as it runs, prefixed with
│— no longer buffered and hidden
.shipnode/pre-deploy.shand.shipnode/post-deploy.shbash hook files removed fromshipnode init— they were never executed during deploy and were misleading. Use.preDeploy()/.postDeploy()inshipnode.config.tsinstead.
shipnode env --file <path>— upload a specific.envfile instead of the default from configshipnode initnow prompts for SSH users to add during setup, generating.shipnode/users.yml- Database configuration prompts in
shipnode init(PostgreSQL, MySQL, SQLite, MongoDB)
- Legacy deploy mode removed — all deployments now use the release/symlink/lock flow;
.zeroDowntime()and.legacy()builder methods replaced by.keepReleases(n) shipnode deploynow streams all remote command output (npm install, PM2 reload, etc.) prefixed withremote:— no longer hidden behind a spinner- rsync transfer progress prints directly to the terminal during deploy
- CLI UI overhauled: replaced plain
console.logoutput with@clack/prompts(spinners, notes, banners) andlistr2task lists — all commands now render structured, coloured output - pnpm install no longer uses
--prodflag — installs all dependencies to prevent pnpm'srunDepsStatusCheckfrom triggering a failed reinstall at PM2 startup
- pnpm 9+
ERR_PNPM_IGNORED_BUILDS: deploy now fails fast with a clear error message when native module build scripts are blocked (Prisma, bcrypt, esbuild, ssh2, etc.) instead of silently starting PM2 with broken modules and timing out on the health check; fix is to runpnpm approve-buildslocally and commit the result - pnpm
runDepsStatusCheckfailure at PM2 startup fixed:pnpm install --prefer-offlineis re-run fromcurrent/after the symlink switch so pnpm's module resolution state matches the directory PM2 uses shipnode deployerror output is now always visible — spinner is stopped before the error propagates- Health check failure now reads PM2 log files directly (
~/.pm2/logs/*.log) instead ofpm2 logs --nostreamwhich was streaming indefinitely - Health check adds a 2-second delay between retries
- SSH authentication: when no
identityFileis set, connection now tries the running SSH agent (SSH_AUTH_SOCK) first, then falls back to default key files (~/.ssh/id_ed25519,id_ecdsa,id_rsa,id_dsa) - SSH auth: agent and key files are now tried independently — previously an active agent socket blocked key file fallback
- PM2 ecosystem uses
exec_mode: forkinstead ofcluster— cluster mode requires a Node.js file, not a package manager script - PM2 ecosystem now runs
pnpm start(ornpm start/yarn start/bun start) instead of a hardcoded entry point file - pnpm/yarn/bun are now installed globally on the remote server if not already present before running
install
shipnode initgenerated config always includes SSHport:field (was missing)shipnode initgenerated config always includes.port()call for backend appsshipnode initgenerated config now includes.build()terminatorshipnode initgenerated config now includes.pkgManager()when detected- Restored database prompts in interactive
shipnode initflow - Fixed
chmodimport: now imported fromnode:fs/promisesat module top level (was broken dynamic import)
writeFileandreadFileimported fromnode:fs/promisesinstead offs-extra— these are not named ESM exports infs-extra
mkdirreplaced withensureDirfromfs-extra—mkdiris not a named ESM export infs-extra- CI: pinned pnpm to v10 to match lockfile format and avoid pnpm v11 build approval errors
- CI: bumped Node.js to 22 in all workflows
Complete rewrite in TypeScript with full feature parity with v1 and new capabilities.
Core
- Zero-downtime releases with Capistrano-style release directories and atomic symlink switch
shipnode init— interactive config generator with framework auto-detection (Next.js, Remix, NestJS, Express, Fastify, AdonisJS, and more)shipnode setup— idempotent server provisioning (Node via mise, PM2, Caddy, UFW, fail2ban)shipnode deploy— full deploy with--dry-runand--skip-buildflagsshipnode doctor— local + remote config health check with optional--securityauditshipnode status— PM2 process status
Release management
shipnode rollback [--steps n]— roll back to any previous releaseshipnode migrate— migrate an existing in-place deploy to zero-downtime structure
Environment
shipnode env— upload local.envto server shared directoryshipnode run <cmd>— one-off remote command; interactive shell forbash/shwith--tty
Process management
shipnode logs [--lines n]— PM2 log tailshipnode restart— PM2 reload with--update-envshipnode stop— stop the applicationshipnode metrics— interactive PM2 monit dashboard over SSH
Security & maintenance
shipnode harden— SSH hardening, UFW firewall, fail2ban setup with confirmation promptsshipnode unlock— clear a stuck deployment lock with age display
Users
shipnode user sync— create/update SSH users from.shipnode/users.ymlshipnode user list— list non-system users on servershipnode user remove <username>— remove a user
Backups
shipnode backup setup— install S3 backup script and systemd timer (hourly/daily/weekly)shipnode backup run— run a backup immediatelyshipnode backup status— show timer status and last run logsshipnode backup list— list recent backups in S3
Cloudflare
shipnode cloudflare init— install cloudflared, create tunnel, configure DNS and Accessshipnode cloudflare audit— verify DNS records and tunnel via Cloudflare APIshipnode cloudflare status— show cloudflared service status- Firewall lockdown to Cloudflare IPs when
lockdownFirewall: true
CI/CD
shipnode ci github— generate GitHub Actions deploy workflowshipnode ci env-sync— sync.envvariables to GitHub repository secrets
Configuration
shipnode config show— display resolved configurationshipnode config validate— validate config file with Zodshipnode config path— print config file location
Customization
shipnode eject [pm2|caddy|all]— eject PM2/Caddy templates to.shipnode/templates/shipnode upgrade— self-update via npm registry
Programmatic API
- Fluent builder API:
shipnode.backend().ssh(...).deployTo(...).build() defineConfig()helper for typed config files- Deploy hooks:
.preDeploy(fn)and.postDeploy(fn)with remote exec context - Full TypeScript types exported from package root
- SSH identity file: was passing file path string to ssh2, now reads key content with
readFileSync - Deploy lock: replaced local PID check (meaningless on remote) with age-based detection (stale after 3600s)
- rsync SSH port: always passes
-e "ssh -p PORT"— previously hardcoded port 22 recordRelease: uses base64 pipe to avoid shell argument length limits on large JSON payloads.shipnodeignore: auto-detected by both backend and frontend strategiesassembleConfig: was silently droppingbackup,cloudflare, andbuildDirfields
- Package renamed from
shipnodeto@devalade/shipnode - Runtime: Node.js via mise instead of nvm
- Config format: TypeScript (
shipnode.config.ts) instead of JSON/YAML - Minimum Node.js version: 18
Versions prior to 2.0.0 are tracked in the v1 branch.