All notable changes to @devalade/shipnode will be documented here.
shipnode -vprints the version. The CLI only accepted Commander's default-V;-vnow works alongside--version.
monitorwatches the whole fleet. With no--onit connects to every server and opens a fleet overview: each app on each server it runs on, the release each replica serves, process and health state, and a one-line verdict per app (a half-finished roll readssplit across 2 releasesand marks the replica that isbehind; an unreachable server keeps its row).Enteropens the per-replica view the old single-server monitor showed.--onand--appstill narrow it. Rollback from the monitor is refused for an app that runs on several servers, since it would split the fleet; useshipnode rollback.- Live log streaming with filters.
fopens a merged, streaming view of every server's logs (PM2, systemd and Caddy access logs) over the existing SSH connections, replacing the 2-secondpm2 logs --nostreampoll and its duplicated or dropped lines. Filter by server (s), app (a), process (p), level (v: all, warn+, error) and text,/regex/or!excludesearch (/), in hide or dim mode (m), with liveERR/WARNcounts. Pause, scroll back, and clear are supported. A dropped connection is retried with backoff without replaying lines already shown. - The monitor has a consistent visual design. Panels carry their title in the border, data sits in aligned tables with column headings, and colour is reserved for state (green ok, amber degraded, red failing) with one accent for titles and selection. A breadcrumb header shows where you are and how fresh the data is (
live,staleonce polls stop arriving), alerts get their own line, and the footer lists the keys for the current view and briefly confirms actions. Tables drop their least important columns on narrow terminals instead of overflowing, and nothing paints a background, so light terminal themes work. shipnode logs --followstreams the same merged logs to the terminal, with--level,--grep,--app,--onand--process.--leveland--grepalso filter the one-shotlogsoutput.
initasks at most six questions instead of up to fifteen: what you're deploying (pre-selected from detection), server IP, port, domain, and for backends whether to install a database or Redis. SSH user and port, deploy path, PM2 name, runtime, health check path and extra users take defaults you can edit in the file.--hostand--domainletinit --non-interactivewrite a complete config. A DB password is no longer written into the config as a literal; it readsprocess.env.DB_PASSWORD. Ifshipnode.config.tsalready exists,initsays so before asking anything.- The default health check accepts any HTTP answer below 500. Apps without a
/healthroute no longer fail their first deploy on a 404. A path set with.healthCheck(path)(orhealthCheck.path) is still held to 2xx/3xx;healthCheck.strictoverrides either way. Failures now say whether nothing answered on the port or the configured route returned 404.
setupcould not reach a fresh server with the configinitwrote.initsetsuser: 'deploy', which only exists aftersetupcreates it. When the server refusesdeploy,setupnow logs in asrootfor that run. If the config uses another user, the "switch ssh.user" hint is shown as before.initaccepted any--domain. The value is written intoshipnode.config.tsas a string, so a stray quote produced a config that no longer loaded.initnow requires a hostname, on the flag and in the prompt.- The first deploy failed with "Remote environment file is missing". When the server has no env file,
deploynow uploads the local one (whatshipnode envwould do). With no local.enveither, it creates an empty one. A customenvFilename that is missing locally still fails. None of this happens in CI, where the env belongs toci env-sync(ADR-0006).
- Blue-green now stops the old colour by default (
blueGreenRetention: 'warm'). After Caddy switches traffic, the previous colour is stopped following a 10-second drain instead of staying in memory, so each app holds one copy. Its release stays on disk. This changes behaviour for configs that never set the option, which used to keep both colours running; set'rollback'to keep that. See ADR-0010.
shipnode rollbackcan start a stopped colour. When the previous colour is not running it pointscurrentat the release that colour ran, starts it from there, waits for its health check, flips traffic, and stops the colour that was serving. A failed start leaves the app as it was.deploy-state.jsonnow recordsblueRelease/greenReleaseto make this possible; an app deployed before this change can roll back this way after its second deploy with it, because the colour it would go back to has no recorded release until then.- A drain before the old colour stops, so a request already in flight when Caddy flips can finish.
- A reaped
wattcolour came back after a reboot. Its unit stayed enabled, so it restarted on whatevercurrentpointed at and held memory. A stopped colour's unit is now disabled as well, and re-enabled when it is started again.
- Opt-in
wattruntime (wattpm)..runtime('watt', { main: 'dist/server.js' })runs the web app as worker threads sharing one port viaSO_REUSEPORTinstead of PM2 processes — no supervisor in the request path and no per-process V8 duplication.instancesbecomes the thread count; workers run as systemd units (shipnode-<app>[-<colour>]). Blue-green,rollback,restart,stop,logs,env,deploy --watch,doctor,status,metricsand the monitor all support it. PM2 stays the default. If your app does not listwattpmand@platformatic/node, shipnode installs both at the version it targets after the normal install; list them yourself to pin the versions. Scaling past one worker needs Linux. See ADR-0009. shipnode rollback --yesskips the confirmation prompts, so a rollback can run from CI or a script.
- Moving an app from PM2 blue-green to
wattfailed withEADDRINUSE. The previous release was still resident under PM2 on the idle colour's port, so the watt unit could not bind it. The idle colour's PM2 process is now deleted by exact name before the colour boots. The watt port guard also never failed (a trailing|| trueswallowed it); it now stops the deploy when the port is bound. - The monitor and
statusshowed0 / 0 MBof host memory on Linux. The observe script ranfree -mb, which procps rejects, leaving the memory line empty. - An unreachable server was blamed on every app in the fleet view. A single-server app showed as a fleet with a missing replica whenever an unrelated server was down, and an app whose only server failed disappeared from the view. A failed poll now records the apps planned for that server and only those are marked unreachable.
- A
wattsystemd unit split its script path at a space inremotePath, and a CR/LF/NUL in a rendered value could add directives. The path is now quoted and such values are rejected.
- The website's watt page now describes the dependency auto-install and that
watt.maxHeapUsedoverridesmaxMemory.
- SQLite in
shipnode backup. Snapshot and restic both copy the configured SQLite file withsqlite3 .backup(WAL-safe). Relative paths resolve undershared/first, thencurrent/.backup setupinstallssqlite3when the database is SQLite; restore notes include a.restoreexample.
- Cloudflare DNS matched records by name alone, so an existing record of the wrong type could be updated instead of the intended one.
.servers({ user, hosts })replaces the named-servers record. The host string is the server's identity everywhere —on,--on, accessoryon, status output — and the order ofhostsis deployment order. Every key besidehosts—user,port,identityFile,proxyMode,proxyCommand— is the default for all of them, and one host departs from the defaults by being an object ({ host: '1.2.3.4', port: 2222 }) rather than a string, keeping every default it does not name.user@hoston a string is the shorthand for overriding just the user; a host settling for nothing getsrooton port 22. The old record form still parses but is undocumented.privateHostis removed. Everything it did works overhost: the load balancer dials the public address, replicas answer on every interface, and cross-server accessory traffic uses the accessory server's host. Cross-region fleets — which have no private network — are now possible..fleet(),.group(),drainWait,readyPathare removed. Anontarget resolving to more than one host is a fleet — rolled one replica at a time, blue-green enabled automatically for fleet backends — and.group()is replaced by naming hosts directly inon.shipnode drain/shipnode undrainare removed. Fleet replicas are no longer drained during a roll (see ADR-0008): blue-green keeps a replica serving until the new release passes health, so there is nothing to drain.
- New load balancer guide — the one-page fleet/LB contract (targets, health check, TLS), the once-only verification loop, and what a failed roll looks like. The README fleet section links it.
- Fleet mode — one app on N servers behind your load balancer. An
ontarget resolving to more than one host makes the app a fleet:shipnode deployrolls through its replicas one at a time. Blue-green stays intra-replica — a replica keeps serving its old release until the new one passes health — so the composition is blue-green within a replica, rolling across replicas. See ADR-0007 and ADR-0008.- Shipnode does not manage the load balancer. You provision it (Hetzner, DigitalOcean, ALB, nginx) and point its health check at your app's own health path. No provider APIs, no credentials, no shipnode-specific settings — SSH remains the only requirement.
- Fleet replicas serve plain HTTP on every interface 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. Give a fleet app a
.domain()so the replica recognises the Host your LB forwards. - Blue-green is enabled automatically for fleet backends with a web port: a replica boots the new release on its idle colour, health-checks it, and only then does traffic move.
.noZeroDowntime()is honoured — it is your outage to have. - 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; the failed replica stays on its previous release, healthy and serving.
- One release id for the whole roll, so
shipnode statuscan distinguish a converged fleet from a half-rolled one.
.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 1.2.3.4cannot 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 accessory server's host when not — so moving an accessory to its own server is a config change, not a code change. Declared env wins.--on <host>ondeploy,rollback,setup,harden,status,logs, andenv.shipnode statusreports fleet convergence — each replica's release, and an explicit warning naming which replicas are on which release when they disagree. A replica with no release 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. 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.- Accessory firewall rules named a server's config key instead of its address. The rule source came from the
serversrecord key. Under.servers({ hosts })the key is the host, so this was accidentally right; under the legacy named record it emittedufw allow from web-a, which is not an address ufw can act on. hardenreported firewall rules it never applied. The executor resolves on a non-zero exit, so a rule ufw refused was discarded silently while the run still printedallowed 5432/tcp from ...and summarised the count as applied — an open accessory port reported as a closed one. Every ufw command's exit code is now checked, failures are shown with ufw's own stderr, and the summary counts only what landed. TheDOCKER-USERscript had the same hole behind its trailing persist step and now fails closed.- A legacy
serversrecord could lose a server silently. The parser decided between the two forms by testing for ahostskey alone, so a record that happened to name a serverhostswas read as the new form and its sibling servers were stripped by validation without an error.
- 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.