All notable changes to @devalade/shipnode will be documented here.
.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.