shipyard

Deploys without holding
your breath.

Shipyard is an atomic-release deploy CLI: zero-downtime SSH/rsync deploys with health-gated promotion and automatic rollback. One static Go binary, one YAML config, no agent on the server.

Quickstart →View on GitHub

The wrong shape

Most "deploy scripts" you'll inherit look like this:

ssh prod 'cd app && git pull && pm2 restart app'

Between git pull and the restart, Apache is serving half-old, half-new files. Composer autoload sees a class the app doesn't yet have on disk. If the new code breaks at boot, pm2 restart still exits 0 — it returned the moment the process was alive, not when it was serving 200s. There's no rollback.

The right shape

Immutable release directories, atomic symlink flip, health-gated promotion, automatic rollback. This is what Capistrano has done since 2010 and what most "deploy scripts" in the wild still don't do.

# Pipeline shipyard runs for you:

  pre_upload hooks (local)     composer install --no-dev
                               npm run build
  SSH connect + lockfile
  SFTP upload artifact         _incoming/20260610190921.tar.gz
  extract                      releases/20260610190921/
  symlink shared/              .env, storage/, bootstrap/cache/
  post_extract hooks (remote)  php artisan migrate --force
  atomic flip                  ln -s + mv -Tf
  post_flip hooks (remote)     sudo systemctl reload apache2
  health probe                 GET /healthz → expect "healthy"
       FAIL ──→ rollback symlink, run on_rollback hooks, exit 4
       PASS ──→ continue
  auto-prune                   releases.keep = 5
  release lock

What you write

One shipyard.yaml:

app: webhook-relay

host:
  ssh: ubuntu@1.2.3.4
  identity_file: ~/.ssh/id_ed25519
  release_root: /var/www/webhook-relay

artifact:
  source: ./deploy-staging/release.tar.gz
  format: tar.gz

shared:
  files: [.env]
  dirs:  [storage, bootstrap/cache]

health_check:
  url: https://api.webhook-relay.example.com/healthz
  expect: "healthy"
  retries: 10
  delay: 3s

hooks:
  pre_upload:   ["composer install --no-dev", "npm run build"]
  post_extract: ["php artisan migrate --force"]
  post_flip:    ["sudo systemctl reload apache2"]
  on_rollback:  ["sudo systemctl reload apache2"]

Then:

$ shipyard deploy

[connect]      ▸ dialing  target=ubuntu@1.2.3.4:22
[connect]      ▸ connected
[lock]         ▸ acquired
[upload]       ▸ uploading  to=_incoming/20260610190921.tar.gz
[extract]      ▸ extracting
[post-extract] ▸ php artisan migrate --force
[flip]         ▸ flipped  from=20260610185512 to=20260610190921
[post-flip]    ▸ sudo systemctl reload apache2
[health]       ▸ attempt  n=1 status=200
[health]       ▸ passed   attempts=1 elapsed=147ms
[prune]        ▸ deleted  release=20260610150318
[done]         ▸ deploy complete  release=20260610190921

Why not Capistrano / Deployer / Kamal

ToolStrengthWhere it doesn't fit
CapistranoBattle-tested Ruby ecosystemRuby-shaped — extension surface assumes Rails idioms
DeployerPHP-native; Composer-awarePHP-shaped — awkward for Node/Go/Python stacks
KamalDocker-first, Rails-team-shapedIf you don't run containers, mostly overhead
GitHub Actions deployCI-shaped; no extra toolBuild artifacts have to be produced wherever Composer lives — usually not on the server

Shipyard's slot: language-agnostic, transport-agnostic, opinionated about safety, unopinionated about your stack. A single static binary you scp anywhere.


This page is deployed by Shipyard. The docs site you’re reading lives at /var/www/shipyard-web/current, which is a symlink Shipyard flipped after a health-gated promotion. Every push to main reaches you the same way. webhook-relay’s deploy migrates onto Shipyard 30 days after v0.1.0 once the failure modes shake out.

→ Quickstart→ Config reference→ CLI reference