| name | shipnode |
| description | Deploy Node.js apps to a single VPS with the shipnode CLI v3 (multi-app workspaces, blue-green releases, PM2, Caddy, SSH, accessories, backups). Use when the user asks about deploying, setting up a server, rolling back, configuring Caddy/PM2, multi-app monorepos, .env on a remote server, accessories/Docker sidecars, or running shipnode commands. |
shipnode (v3)
Package: @devalade/shipnode (v3 line; alpha tag may be newer than latest).
Quick start
shipnode init
shipnode setup
shipnode env
shipnode deploy
Config models
Workspace (shared): ssh, servers, deployTo, nodeVersion, pkgManager, installCommand, database, redis, backup, cloudflare, aliases, registry, accessories.
Per-app: name, appType, appRoot, domain, port / pm2 / worker, healthCheck, envFile, keepReleases, zeroDowntime, sharedDirs/sharedFiles, hooks, dependsOn, on (server target), caddy.
Single-app 2.x style still works (top-level .backend() etc. → synthesized as apps[0]). Prefer explicit .apps([...]) for monorepos.
Single app
import { shipnode } from '@devalade/shipnode';
export default shipnode
.backend()
.ssh({ host: '1.2.3.4', user: 'deploy' })
.deployTo('/var/www/myapp')
.pm2('myapp', { instances: 2 })
.port(3000)
.worker({ name: 'worker', command: 'node dist/worker.js' })
.domain('api.example.com')
.healthCheck('/health')
.nodeVersion('22')
.pkgManager('pnpm')
.build();
Frontend: .frontend(), .buildDir('dist'), no .pm2() / .port().
Multi-app workspace
Layout on server: <remotePath>/<app-name>/releases/<ts>/ + current symlink.
import { shipnode, app } from '@devalade/shipnode';
const api = app()
.backend()
.name('api')
.appRoot('apps/backend')
.domain('api.example.com')
.port(3333)
.envFile('apps/backend/.env.production')
.preDeploy(async ({ exec }) => {
await exec('pnpm db:apply');
});
const web = app()
.frontend()
.name('web')
.appRoot('apps/web')
.domain('example.com')
.buildDir('dist')
.envFile('apps/web/.env.production');
export default shipnode
.ssh({ host: '1.2.3.4', user: 'deploy' })
.deployTo('/var/www/example')
.nodeVersion('24')
.pkgManager('pnpm')
.apps([api, web])
.build();
Target one app: shipnode deploy --app api, shipnode logs --app web. rollback requires --app.
Workflows
First deploy
shipnode init
shipnode setup — creates deploy user by default (--no-deploy-user to skip)
shipnode env (per app if multi-app: --app api)
shipnode deploy
CI/CD
Runtime env stays on the VPS by default: upload it with shipnode env, then generate a code-only workflow with shipnode ci github. To opt one app into GitHub-managed env, run shipnode ci env-sync --app api --environment production, then generate with the matching --app api --environment production --sync-env flags. Shipnode stores the complete dotenv file as one Environment secret and uploads it with --no-reload immediately before deployment.
Releases
Every deploy uses release dirs + atomic current symlink. Failed deploys revert current (when a previous release exists) and record status: 'failed'.
Blue-green (automatic): backends with a domain and web .port() boot the idle colour → health (web only) → reload workers → flip Caddy. The green port defaults to an uncommon port offset by 10,000 (3000 → 13000), subtracting 10,000 above 55535. Use .zeroDowntime(altPort?) to force the mode or choose the alternate port, and .noZeroDowntime() to opt out. Rollback is an instant Caddy flip to the previous colour (one step).
Rollback
shipnode rollback --app api
shipnode rollback --app api --steps 3
Migrations
.preDeploy(async ({ exec }) => {
await exec('pnpm db:apply');
})
prisma generate belongs in package.json "build", not preDeploy.
Accessories (Docker sidecars)
.accessories({
postgres: {
image: 'postgres:16',
port: '5432:5432',
env: { POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' },
volumes: ['pgdata:/var/lib/postgresql/data'],
},
})
shipnode accessory status
shipnode accessory logs postgres
shipnode registry login
shipnode secret set POSTGRES_PASSWORD
Backups
.backup({
s3Bucket: 'my-backups',
s3Endpoint: 'https://<account>.r2.cloudflarestorage.com',
strategy: 'restic',
schedule: 'daily',
keepDaily: 7,
})
shipnode backup setup
shipnode backup run
shipnode backup list
shipnode backup restore latest --target /tmp/restore
Security / Cloudflare
shipnode doctor --security
shipnode harden
shipnode cloudflare init
Monitor
shipnode monitor
shipnode monitor --app api
Day-to-day
shipnode status [--app name]
shipnode logs [--app name] [--lines 500]
shipnode restart [--app name]
shipnode stop [--app name]
shipnode run "pnpm db:apply" [--app name]
shipnode run bash
shipnode deploy --dry-run
shipnode deploy --skip-build
shipnode unlock
shipnode config show [--app name]
shipnode user add alice --key ~/.ssh/alice.pub --sudo
See REFERENCE.md for the full v3 command & builder tables.
Still on 2.x / reading an old config? See REFERENCE-v2.md.
Troubleshooting
Build fails on remote, works locally
Remote runs full build (e.g. next build + tsc). Run pnpm build locally first.
Prisma types missing at build
Put prisma generate in "build" script. Migrations stay in .preDeploy().
PORT
.port(n) → PM2 env.PORT. Next.js 13.5+ reads it automatically. Multi-app: unique ports per app.
pnpm ignored build scripts
Deploy fails fast on ERR_PNPM_IGNORED_BUILDS. Fix: pnpm approve-builds, commit, redeploy.
Stuck lock
Lock is .shipnode/deploy.lock/ (mkdir). Clear with shipnode unlock after confirming no deploy running.