npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.site

ship 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

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.com is valid, a.b.example.com is not). Caddy runs in a Docker container with /srv/deploys mounted 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: ship only 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 find ship. 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 value

Commands

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 server

ship 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_modules
  • Subdomain: --subdomain <s> (alias --name <s>). Omitted → a random adjective-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). .git and .DS_Store are 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 browser

list 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:

  1. installs Docker,
  2. creates the unprivileged deploy user and installs your SSH public key,
  3. makes the host /srv/deploys directory,
  4. uploads the Caddy image (Dockerfile) + compose.yml to /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),
  5. runs docker compose up -d --build — Caddy runs in a container serving /srv/deploys mounted 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 demo

The domain must be DNS-only (not Cloudflare-proxied), so it resolves to the real server IP that ssh/rsync connect 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

  1. Add the domain to Cloudflare (Free plan) and switch its nameservers at your registrar to the ones Cloudflare gives you.
  2. In DNS → Records, add (all DNS only / grey cloud):
    • * A → <your VPS IP> (wildcard — required)
    • @ A → <your VPS IP> (apex — only needed for ship 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 deploy user; provision uses 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/rsync calls.
  • 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" in authorized_keys (see server/SETUP.md). Trade-off: ship list/rm would 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   # macOS

Host-key prompts on every new subdomain. Each random subdomain is a "new host" to SSH. Add to ~/.ssh/config:

Host *.<domain> <domain>
    StrictHostKeyChecking accept-new

First 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 off

Unmatched 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-tested

Layout:

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 plans

Zero runtime dependencies; tests use Node's built-in node:test.


Part of the cli-tools monorepo (@nightlybuildgroup/ship).