Skip to content

Quick start

This walkthrough covers the full happy path — provisioning a fresh server, generating a config, deploying your first release, rolling back, and wiring CI. Skip ahead if a section doesn’t apply.

You needWhy
A VPS running Ubuntu 22.04+ or Debian 12+ShipNode provisions Node, PM2, Caddy on this OS.
SSH access as root with your public key in /root/.ssh/authorized_keysThe simplest path — what most fresh VPS images give you.
A domain name with an A record pointing at the VPS public IPRequired. Caddy uses the domain to issue an HTTPS cert automatically — no domain, no TLS.
Node.js 18+ locallyThe CLI is a Node.js package.

Before anything else, log into your DNS provider (Cloudflare, Namecheap, Route 53, OVH…) and create an A record:

TypeNameValueTTL
Aapi (or @ for the apex)203.0.113.10 (your VPS IP)Auto / 300

Verify it resolves before you deploy:

Terminal window
dig +short api.example.com
# should print your VPS IP

DNS can take a few minutes (sometimes longer with Cloudflare proxying — turn the orange cloud off initially so Caddy can issue the cert via HTTP-01).

Terminal window
# npm
npm install -D @devalade/shipnode
# pnpm
pnpm add -D @devalade/shipnode
# yarn
yarn add -D @devalade/shipnode
# bun
bun add -d @devalade/shipnode

shipnode lives in your project as a dev dependency so every collaborator and your CI uses the same version.

Terminal window
npx shipnode init

This prompts for framework, package manager, app type, SSH target, domain, and port — then writes shipnode.config.ts. You can rerun init or edit the file by hand at any time. See the configuration reference for every option.

A minimal backend config:

import { shipnode } from '@devalade/shipnode';
export default shipnode
.backend()
.ssh({ host: '203.0.113.10', user: 'root' })
.deployTo('/var/www/api')
.pm2('api', { instances: 2 })
.port(3000)
.domain('api.example.com')
.healthCheck('/health')
.nodeVersion('22')
.pkgManager('pnpm')
.build();
Terminal window
npx shipnode setup

One-time, idempotent. Installs mise, Node.js, PM2 (+ pm2-logrotate), Caddy, and your package manager. Re-running it is safe — it skips anything already present.

Verify with:

Terminal window
npx shipnode doctor

If anything is red, fix it before deploying.

Most apps need environment variables in production — database URLs, API keys, signing secrets. Keep them in a local file (do not commit it) and push it to the server once:

Terminal window
npx shipnode env --file .env.production

What this does, step by step:

  1. Reads .env.production from your project root locally.
  2. SSHes into the server using the host + user from shipnode.config.ts.
  3. Writes the file to <deployPath>/shared/.env — outside any release directory, owned by the deploy user, chmod 600.
  4. Every current and future release gets .env symlinked in from shared/.env, so all releases share the same env without you redeploying when a value changes.
  5. PM2 picks up new values on the next shipnode restart or shipnode deploy (both pass --update-env).

This separation matters: secrets live in shared/, code lives in releases/<timestamp>/. Rolling back a release does not roll back your secrets, and rotating a secret does not require a redeploy.

Terminal window
npx shipnode deploy

What happens:

rsync ./ -> /var/www/api/releases/20260524160000
install pnpm install --frozen-lockfile
build pnpm run build
symlink current -> releases/20260524160000
pm2 reload api --update-env
health GET /health 200 OK 47ms
deployed https://api.example.com

If the health check fails, the symlink stays on the previous release and the failed one is discarded. No partial outage.

Terminal window
npx shipnode status # PM2 state + current release
npx shipnode logs # stream logs
npx shipnode metrics # PM2 CPU/memory dashboard

Need to run a one-off command on the server inside the current release?

Terminal window
npx shipnode run "node scripts/migrate.js"

Something off in production?

Terminal window
npx shipnode rollback --steps 1

The current symlink moves back one release and PM2 reloads. The default keepReleases is 5, so you have headroom.

Generate a ready-to-use GitHub Actions workflow:

Terminal window
npx shipnode ci github
npx shipnode ci env-sync --all

This drops .github/workflows/deploy.yml and pushes your .env keys to GitHub repository secrets. See the CI/CD guide for the secrets it expects.

Terminal window
npx shipnode harden

Locks down SSH (key-only, no root), enables UFW, installs fail2ban, and turns on unattended security upgrades.

Audit it anytime:

Terminal window
npx shipnode doctor --security

Permission denied (publickey) on any command

Section titled “Permission denied (publickey) on any command”

The SSH connection is failing before shipnode does anything. Confirm you can connect manually with the same host and user from your config:

Terminal window
ssh root@203.0.113.10

If that prompts for a password or fails, your public key isn’t in the server’s ~/.ssh/authorized_keys. Fix that first.

Your A record hasn’t propagated yet, or it points somewhere else. Wait a few minutes and retry. With Cloudflare, turn the proxy (orange cloud) off until Caddy has issued the certificate — the HTTP-01 challenge needs a direct connection to your server on port 80.

Caddy needs ports 80 and 443 reachable from the public internet to complete the ACME challenge. Common causes:

  • Hosting firewall blocks 80/443 (check your provider’s security group / network rules).
  • UFW is enabled and didn’t allow web traffic — shipnode harden opens 80 and 443 automatically, but a manual UFW config might not.
  • Cloudflare proxy is on and rewriting the challenge. Toggle it off, deploy, then turn it back on.

Inspect Caddy logs on the server:

Terminal window
sudo journalctl -u caddy -n 100 --no-pager

The release was discarded and the symlink stayed on the previous one — your app isn’t healthy. Run:

Terminal window
npx shipnode logs

Most common reasons:

  • The healthCheck path in shipnode.config.ts doesn’t exist in your app (404).
  • The app didn’t bind to the port from .port(...).
  • A required env var isn’t set — re-check npx shipnode env --file .env.production.

Deploy is locked even though nothing is running

Section titled “Deploy is locked even though nothing is running”

A previous deploy was killed mid-flight and left a stale lock at <deployPath>/.shipnode/deploy.lock. After confirming nothing is actually deploying:

Terminal window
npx shipnode unlock

Node version mismatch / build fails on the server

Section titled “Node version mismatch / build fails on the server”

shipnode setup installs Node via mise based on nodeVersion in your config. If you bumped that field after running setup, re-run it:

Terminal window
npx shipnode setup

When in doubt:

Terminal window
npx shipnode doctor

It validates the config, SSH, sudo, Node, PM2, Caddy, disk space, and the live domain. Fix what it flags before running deploy.