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.
0. Prerequisites
Section titled “0. Prerequisites”| You need | Why |
|---|---|
| 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_keys | The simplest path — what most fresh VPS images give you. |
| A domain name with an A record pointing at the VPS public IP | Required. Caddy uses the domain to issue an HTTPS cert automatically — no domain, no TLS. |
| Node.js 18+ locally | The CLI is a Node.js package. |
Point your domain at the server
Section titled “Point your domain at the server”Before anything else, log into your DNS provider (Cloudflare, Namecheap, Route 53, OVH…) and create an A record:
| Type | Name | Value | TTL |
|---|---|---|---|
A | api (or @ for the apex) | 203.0.113.10 (your VPS IP) | Auto / 300 |
Verify it resolves before you deploy:
dig +short api.example.com# should print your VPS IPDNS 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).
1. Install shipnode in your project
Section titled “1. Install shipnode in your project”# npmnpm install -D @devalade/shipnode
# pnpmpnpm add -D @devalade/shipnode
# yarnyarn add -D @devalade/shipnode
# bunbun add -d @devalade/shipnodeshipnode lives in your project as a dev dependency so every collaborator and your CI uses the same version.
2. Generate the config
Section titled “2. Generate the config”npx shipnode initThis 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();3. Provision the server
Section titled “3. Provision the server”npx shipnode setupOne-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:
npx shipnode doctorIf anything is red, fix it before deploying.
4. Upload secrets
Section titled “4. Upload secrets”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:
npx shipnode env --file .env.productionWhat this does, step by step:
- Reads
.env.productionfrom your project root locally. - SSHes into the server using the host + user from
shipnode.config.ts. - Writes the file to
<deployPath>/shared/.env— outside any release directory, owned by the deploy user,chmod 600. - Every current and future release gets
.envsymlinked in fromshared/.env, so all releases share the same env without you redeploying when a value changes. - PM2 picks up new values on the next
shipnode restartorshipnode 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.
5. Deploy
Section titled “5. Deploy”npx shipnode deployWhat happens:
rsync ./ -> /var/www/api/releases/20260524160000install pnpm install --frozen-lockfilebuild pnpm run buildsymlink current -> releases/20260524160000pm2 reload api --update-envhealth GET /health 200 OK 47msdeployed https://api.example.comIf the health check fails, the symlink stays on the previous release and the failed one is discarded. No partial outage.
6. Confirm and operate
Section titled “6. Confirm and operate”npx shipnode status # PM2 state + current releasenpx shipnode logs # stream logsnpx shipnode metrics # PM2 CPU/memory dashboardNeed to run a one-off command on the server inside the current release?
npx shipnode run "node scripts/migrate.js"7. Roll back
Section titled “7. Roll back”Something off in production?
npx shipnode rollback --steps 1The current symlink moves back one release and PM2 reloads. The default keepReleases is 5, so you have headroom.
8. Wire CI (optional)
Section titled “8. Wire CI (optional)”Generate a ready-to-use GitHub Actions workflow:
npx shipnode ci githubnpx shipnode ci env-sync --allThis drops .github/workflows/deploy.yml and pushes your .env keys to GitHub repository secrets. See the CI/CD guide for the secrets it expects.
9. Harden the server (recommended)
Section titled “9. Harden the server (recommended)”npx shipnode hardenLocks down SSH (key-only, no root), enables UFW, installs fail2ban, and turns on unattended security upgrades.
Audit it anytime:
npx shipnode doctor --securityTroubleshooting
Section titled “Troubleshooting”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:
ssh root@203.0.113.10If that prompts for a password or fails, your public key isn’t in the server’s ~/.ssh/authorized_keys. Fix that first.
dig returns no IP / wrong IP
Section titled “dig returns no IP / wrong IP”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 can’t issue an HTTPS certificate
Section titled “Caddy can’t issue an HTTPS certificate”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 hardenopens 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:
sudo journalctl -u caddy -n 100 --no-pagerHealth check fails after deploy
Section titled “Health check fails after deploy”The release was discarded and the symlink stayed on the previous one — your app isn’t healthy. Run:
npx shipnode logsMost common reasons:
- The
healthCheckpath inshipnode.config.tsdoesn’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:
npx shipnode unlockNode 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:
npx shipnode setupPre-flight check before anything else
Section titled “Pre-flight check before anything else”When in doubt:
npx shipnode doctorIt validates the config, SSH, sudo, Node, PM2, Caddy, disk space, and the live domain. Fix what it flags before running deploy.
What’s next
Section titled “What’s next”- shipnode.config.ts reference — every method, every option
- Multi-environment — add a staging deploy
- Workers — long-running PM2 processes alongside the web app
- Cloudflare Tunnel — close inbound ports entirely
- Backups — scheduled
pg_dump+ file backups to S3