@nightlybuildgroup/ship
v1.0.0
Published
Push static folders to *.nightlybuild.site
Readme
ship
A small, self-hosted, surge.sh / Vercel-style static deploy tool.
ship push ./dist
# → https://blue-unicorn.nightlybuild.siteship push rsyncs a folder to your own VPS over SSH; Caddy
serves it at https://<subdomain>.<your-domain> with an automatic wildcard TLS
certificate. There is no per-deploy server code — Caddy is config-only, your SSH key
is the only credential, and any subdomain you invent works instantly.
Contents
- How it works
- Requirements
- Install
- Configuration
- Commands
- Multiple domains
- Setting up a server from scratch
- Security
- Troubleshooting
- Error pages & SPA fallback
- Development
How it works
laptop VPS (e.g. nightlybuild.site → <VPS_IP>)
┌────────────────┐ ┌──────────────────────────────────────────┐
│ ship push ./d │ rsync over SSH │ sshd ── deploy user │
│ (Node CLI) │ ────────────────────► │ └─ /srv/deploys/<sub>/ │
│ your SSH key │ │ │
└────────────────┘ │ Caddy (config only) │
│ *.<domain> → /srv/deploys/{subdomain} │
https://<sub>.<domain> ◄────────────────│ file_server + one wildcard DNS-01 cert │
└──────────────────────────────────────────┘- DNS: a wildcard
*.<domain>A-record points every subdomain at the VPS, so a brand-new subdomain resolves with no DNS change. - TLS: Caddy obtains a single
*.<domain>certificate via the ACME DNS-01 challenge through Cloudflare, then renews it automatically. One cert covers every subdomain, so there is no per-deploy issuance and no rate-limit risk. - Serving: each subdomain is just a directory under
/srv/deploys.*matches a single DNS label, so the namespace is intentionally flat (blog.example.comis valid,a.b.example.comis not). Caddy runs in a Docker container with/srv/deploysmounted read-only — sandboxed, and unable to alter your deploys. - Auth: the CLI shells out to the system
rsync/ssh; your SSH key is the credential. No OAuth, no tokens to manage on the client.
Requirements
Laptop: Node ≥ 18 and the system rsync, ssh, scp (preinstalled on macOS
and most Linux).
nvm users:
shiponly runs on Node ≥ 18. If your nvm default is an older version, make an ≥ 18 version the default (nvm alias default 24) — otherwise a fresh terminal won't findship. See Troubleshooting.
Server: a Debian/Ubuntu VPS you have root SSH access to. Everything else
(ship provision) sets up for you.
Install
From the repo:
npm link # or: npm install -g .
ship init # or just: ship config set default-domain <your-domain>Zero runtime dependencies — it's plain Node built-ins.
Configuration
Config lives in ~/.shipconfig.json:
{
"defaultDomain": "nightlybuild.site",
"sshUser": "deploy",
"deployRoot": "/srv/deploys",
"sshKey": "~/.ssh/id_ed25519"
}| Field | Default | Meaning |
|---|---|---|
| defaultDomain | — (required for deploys) | domain used when --domain is omitted |
| sshUser | deploy | SSH user for deploys (the user provision creates) |
| deployRoot | /srv/deploys | where deploys live on the server |
| sshKey | (default key / agent) | path to a specific private key, optional |
Set fields with ship config set:
ship config set default-domain nightlybuild.site
ship config set ssh-user deploy
ship config get # print the whole resolved config
ship config get default-domain # print one valueCommands
ship init configure the CLI (~/.shipconfig.json)
ship config get [key] show config (or one value)
ship config set <key> <value> default-domain | ssh-user | deploy-root | ssh-key
ship push [dir] [--subdomain <s>] [--domain <d>] [--overwrite|--append] [--spa|--no-spa] [--exclude <glob>]
ship list [--domain <d>] list deploys
ship rm <sub> [--domain <d>] delete a deploy
ship open <sub> [--domain <d>] open a deploy in the browser
ship provision <domain> [--host <addr>] [--as <user>] set up a new servership push
ship push # deploy ./ to an auto-generated subdomain
ship push ./dist # deploy a specific folder
ship push ./dist --subdomain blog # deploy to a stable URL: https://blog.<domain>
ship push ./dist --domain other.com # deploy to a different (already set up) domain
ship push ./dist --exclude '*.map' --exclude node_modulesSubdomain:
--subdomain <s>(alias--name <s>). Omitted → a randomadjective-adjective-noun(e.g.bold-amber-falcon, ~2.1M combinations). Auto-generated names are checked against the server and regenerated on collision, so they never clobber an existing deploy.Redeploys / collisions: pushing to a subdomain that already exists is, by default, an overwrite — the folder is mirrored with
rsync --delete, so the live site exactly matches your local folder (stale files removed). In an interactive terminal you get a prompt:Subdomain "blog" already exists. [o]verwrite / [a]ppend / [c]ancel?Skip the prompt with
--overwrite(mirror) or--append(merge — keep remote files not present locally). In a non-interactive shell (CI), it overwrites without asking.Safety: an empty source directory is refused (it would wipe the remote via
--delete)..gitand.DS_Storeare always excluded.
ship list / ship rm / ship open
ship list # list deploys on the default domain
ship list --domain other.com
ship rm blog # delete a deploy (asks to confirm; reports if it doesn't exist)
ship open blog # open https://blog.<domain> in your browserlist connects to the domain apex, so it needs an apex @ A-record (see
DNS). push/rm/open work over the wildcard without it.
ship config / ship init
ship init is an interactive first-run that writes all four config fields; ship config
set/get edits them individually afterward. Both write ~/.shipconfig.json.
ship provision
Sets up a fresh server so a domain becomes deploy-ready, in one command:
CLOUDFLARE_API_TOKEN='<token>' ship provision nightlybuild.site --host <VPS_IP>It connects as root (override with --as), then idempotently:
- installs Docker,
- creates the unprivileged
deployuser and installs your SSH public key, - makes the host
/srv/deploysdirectory, - uploads the Caddy image (
Dockerfile) +compose.ymlto/opt/ship, generates the*.<domain>Caddyfile, and writes the Cloudflare token to a root-only/opt/ship/.env(fed over stdin, never a CLI argument), - runs
docker compose up -d --build— Caddy runs in a container serving/srv/deploysmounted read-only.
The deploy user has no Docker access (stays least-privilege); a compromised Caddy is
sandboxed in the container and can't alter the read-only deploys.
--host lets you target the raw IP before DNS resolves. The token is read from
$CLOUDFLARE_API_TOKEN, or prompted (hidden) if unset. Afterward it reminds you to add
the DNS records (it can't touch your Cloudflare account from the box).
Multiple domains
There's no per-domain config to maintain. ship derives the SSH target from the
hostname itself: push/rm connect to <sub>.<domain> (resolved via the wildcard),
list to the apex. As long as a domain's wildcard points at a ship-provisioned server
(same deploy user, same /srv/deploys), --domain other.com just works:
ship provision other.com --host 203.0.113.10 # set the box up once
ship push ./site --domain other.com --subdomain demoThe domain must be DNS-only (not Cloudflare-proxied), so it resolves to the real server IP that
ssh/rsyncconnect to.
Setting up a server from scratch
See server/SETUP.md for the full runbook (and the manual equivalent
of ship provision). The short version:
Cloudflare DNS
- Add the domain to Cloudflare (Free plan) and switch its nameservers at your registrar to the ones Cloudflare gives you.
- In DNS → Records, add (all DNS only / grey cloud):
*A →<your VPS IP>(wildcard — required)@A →<your VPS IP>(apex — only needed forship list)
Grey cloud matters: Caddy terminates TLS itself, so traffic must reach the VPS directly, not Cloudflare's proxy.
Cloudflare API token
Caddy needs a token to solve the DNS-01 challenge. Create a scoped one:
- Cloudflare → My Profile → API Tokens → Create Token → "Edit zone DNS" template.
- Zone Resources: Include → Specific zone → your domain.
- This grants exactly Zone → DNS → Edit and Zone → Zone → Read — the minimum.
- Optional: restrict to your VPS IP under Client IP Address Filtering. Leave it with no expiry (Caddy renews certs periodically and would break on a dead token).
Pass it to ship provision via $CLOUDFLARE_API_TOKEN. It lives only in the server's
root-only caddy.service.d/cloudflare.conf — never in the repo, never in your config.
Security
- Auth: SSH key only. Deploys go as the unprivileged
deployuser;provisionuses root just for the one-time setup. - Validation: subdomains and domains are validated against strict patterns before
they're ever interpolated into a remote command — the injection defense for the
ssh/rsynccalls. - Secrets: the Cloudflare token is sent to the server over the SSH connection (stdin),
not as a process argument (so it never shows in
ps), and is stored 0600 root-only. - Optional hardening: you can jail the deploy key to rsync-only with a forced
command="rrsync /srv/deploys"inauthorized_keys(seeserver/SETUP.md). Trade-off:ship list/rmwould need reworking, so it's opt-in.
Troubleshooting
ship: command not found (nvm). ship is linked under one Node version and needs
Node ≥ 18. Check node -v; if your nvm default is older, run nvm alias default 24,
open a new terminal, and try again. (Or nvm use <≥18> then npm link again.)
ship list → "Could not resolve hostname" right after adding the apex record. Your
machine cached the old "no record" answer. Confirm Cloudflare has it
(dig +short A <domain> @1.1.1.1), then flush:
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder # macOSHost-key prompts on every new subdomain. Each random subdomain is a "new host" to
SSH. Add to ~/.ssh/config:
Host *.<domain> <domain>
StrictHostKeyChecking accept-newFirst deploy to a new subdomain serves a TLS error for a few seconds. Caddy is issuing the wildcard cert on the first HTTPS hit; wait a moment and retry. Subsequent subdomains are instant (the wildcard already covers them).
API token '' appears invalid during provisioning. Out of date — fixed. Re-pull and
re-run ship provision.
Error pages & SPA fallback
Both are per-deploy and need no server config — they're driven by what's in the folder.
Custom 404 page. Include a 404.html in your deploy and it's served (with a real 404
status) for any missing path inside that deploy. A missing subdomain still returns "No
deploy at this subdomain"; a missing file in a real deploy returns your 404.html (or a
plain 404 Not Found if you didn't include one).
SPA fallback (client-side routing). Serve your app shell for unmatched routes:
ship push ./dist --subdomain app --spa # enable; sticky for this subdomain
ship push ./dist --subdomain app # later plain pushes stay SPA
ship push ./dist --subdomain app --no-spa # turn it offUnmatched routes serve 200.html if present, otherwise index.html, with a 200 status.
Including a 200.html in the folder also enables SPA (surge convention) without the flag.
The --spa setting is stored as a marker that's excluded from the --delete mirror, so
redeploys keep it.
Development
npm test # node --test — pure-logic modules are unit-testedLayout:
bin/ship.js CLI entrypoint (arg parsing + command routing)
src/
config.js ~/.shipconfig.json load/save, defaults, domain resolution
subdomain.js subdomain + domain validation, name generation
mode.js overwrite / append / prompt resolution
remote.js rsync / ssh argv builders, host derivation
prompt.js no-echo secret prompt
commands/ init, config, push, list, rm, open, provision
server/
Caddyfile the wildcard site block
provision.sh idempotent server setup (run by `ship provision`)
SETUP.md manual server runbook
docs/superpowers/ design specs + implementation plansZero runtime dependencies; tests use Node's built-in node:test.
Part of the cli-tools monorepo (@nightlybuildgroup/ship).
