Skip to content

Using ship on its own

Everything infra ship does to deploy a Worker, it does with ship. And ship doesn’t need forge-effect’s declarations: it deploys any Worker described the way Cloudflare’s own tools describe one. If you have a Worker already, plain TypeScript, Hono, a Vite app, you can ship it with the stepped rollouts, smoke tests and undo from Plan and ship.

Here’s a Worker’s config, as Cloudflare’s cf CLI reads it:

packages/ship/fixtures/hello/cloudflare.config.ts
import { bindings, defineConfig } from '@cloudflare/config';
import * as entrypoint from './src/worker.ts' with { type: 'cf-worker' };
export default defineConfig((ctx) => ({
worker: {
name: process.env['SHIP_NAME'] ?? 'ship-hello',
entrypoint,
compatibilityDate: '2026-10-01',
env: {
GREETING: bindings.text(process.env['SHIP_GREETING'] ?? 'hello from ship'),
BROKEN: bindings.text(ctx.mode === 'broken' ? 'yes' : 'no'),
CACHE: bindings.kv(),
DB: bindings.d1(),
VERSION: bindings.versionMetadata(),
},
},
}));

Ask ship what it would do, with no requests at all:

$ pnpm ship fixtures/hello --dry-run
would ship ship-hello from …/packages/ship/fixtures/hello/cloudflare.config.ts
1 module (0.8 KB), main worker.js
binds GREETING (plain_text)
binds BROKEN (plain_text)
binds VERSION (version_metadata)
provisions CACHE: kv ship-hello-cache, unless it exists
provisions DB: d1 ship-hello-db, unless it exists
rolls out 10% → 50% → 100%, smoke-testing /

CACHE and DB name no existing namespace or database, so ship makes them, named after the Worker and the binding. Then, for real:

0.00s shipping ship-hello
0.02s ✓ bundled 0.8 KB
1.07s ✓ created KV namespace ship-hello-cache
1.33s ✓ created D1 database ship-hello-db
2.74s ✓ created Worker ship-hello, live
64.89s ✓ smoke test passed: 3 × GET /
65.44s ✓ shipped ship-hello: https://ship-hello.<account>.workers.dev

(That smoke test waited out a minute of a brand new workers.dev URL warming up. They’re usually quicker.)

From packages/ship, with CLOUDFLARE_API_TOKEN set:

Terminal window
pnpm ship path/to/cloudflare.config.ts # bundles it with esbuild, and ships
pnpm ship path/to/project --prebuilt # what `cf build` or Vite built
pnpm ship plan path/to/project # what a ship would do, read from the account
pnpm ship destroy path/to/project --yes # the Worker, and what ship made for it

The project is a cloudflare.config.ts or its folder.

  • With a config, ship loads it with Cloudflare’s own loader (@cloudflare/config), evaluated for --mode, and bundles the entrypoint with esbuild into one module, as workerd loads it. Static files come from public/ beside it, or --assets <dir>.
  • With --prebuilt, it deploys the Build Output that cf build and Cloudflare’s Vite plugin write to .cloudflare/output/, as cf deploy would: the Worker’s modules by their manifest, and its assets. It refuses an output built in another mode, one with a symlink anywhere, or a module outside its bundle, so a deploy never uploads a file from elsewhere.

It never uses Wrangler’s deploy code. Every call goes through an Effect SDK generated from Cloudflare’s API.

  • Provisioning. A KV, D1, R2 or queue binding that names no resource gets one. One that exists already is used as it is.
  • A version before traffic. An existing Worker gets a new version with no traffic, smoke-tested on its own preview URL before any user sees it.
  • Steps, 10% → 50% → 100% by default, each smoke-tested, pinned to the new version.
  • Smoke tests that check who answered, with a small probe module in front of your Worker’s, as Plan and ship explains.
  • Static assets, content-hashed, uploaded before the Worker, only those Cloudflare lacks.
  • Durable Objects, as Cloudflare’s declarative exports. A version that changes them needs --atomic.
  • Routes and custom domains, attached once it’s live; queue consumers, set once it’s live, and removed when the config stops naming them.
  • Secrets, with --secret NAME, set from the environment variable NAME, never from an argument.
  • All or nothing. On a failure or Ctrl-C, everything is undone in reverse; resources that existed before are never touched.

A Vite app built with Cloudflare’s plugin, from its Build Output. Its first ship failed, rightly:

4.73s ✓ created Worker ship-vite, live
18.69s ↩ deleted Worker ship-vite
19.07s ↩ deleted KV namespace ship-vite-visits
failed: smoke test of https://ship-vite.<account>.workers.dev/ failed: the probe got no version back, so the new Worker didn't answer

The app’s assets answered the probe’s path with the single-page app’s index.html, so the smoke test couldn’t tell which version it reached, and ship undid everything: the Worker, and the namespace it had made. A smoke test that only checked for a 200 would have passed. ship now routes the probe’s path to the Worker ahead of the assets, and the next try went through:

0.00s ✓ 0.4 KB of modules, 2 asset files
1.03s ✓ created KV namespace ship-vite-visits
3.69s ✓ uploaded 2 of 2 asset files (1.0 KB)
4.73s ✓ created Worker ship-vite, live
31.42s ✓ smoke test passed: 3 × GET /, answered by the new Worker
31.43s ✓ shipped ship-vite: https://ship-vite.<account>.workers.dev
Default
--prebuilt deploy the Build Output rather than bundling the config
--worker <name> the default the Build Output Worker to deploy
--mode <mode> the config’s ctx.mode; a Build Output must be built in it
--steps 10,50,100 10,50,100 percentages of traffic, ending at 100
--interval 5s 5s how long each step lasts before its smoke test
--smoke /path / the path the smoke test requests
--requests 3 3 successes in a row that pass a smoke test
--no-smoke no smoke tests, such as for a Worker with no HTTP
--no-verify smoke tests that don’t check which version answered
--assets <dir> public/ a config’s static files
--atomic a version that changes exports, at 100% at once
--secret NAME set a declared secret from the environment variable NAME
--message <text> annotate the version and deployments
--via <url> ship through a deployer Worker (below)
--json ship plan as JSON
--dry-run load, bundle and describe, with no requests

The token needs Workers Scripts Edit, plus Workers KV Storage Edit, D1 Edit, R2 Edit or Queues Edit for what it provisions. The account is the config’s accountId, or CLOUDFLARE_ACCOUNT_ID, or the token’s first account.

ship destroy deletes the Worker, then the resources ship would have provisioned for it, found by their names, but only those the Worker is bound to. A resource that merely has such a name is left alone, as is one the config names by id.

  • infra if your app is declared in Effect: it reads the declarations, checks them, ships every Worker of the app together, keeps state, and does environments.
  • ship for one Worker that already has a cloudflare.config.ts or a Build Output. It has no state of its own: it reads the account each time.

ship is also a library, @forge-effect/ship, with no Node APIs in its core: ship(deployable, options), planShip and destroy. That’s how infra uses it, and how it runs inside a Worker.

Shipping from inside Cloudflare: the deployer

Section titled “Shipping from inside Cloudflare: the deployer”

packages/deployer is a Worker that runs ship’s core. You send it a Worker’s settings, modules and assets in one request, and it provisions, uploads, rolls out and smoke-tests from inside Cloudflare, streaming the log back. The machine that ships holds no Cloudflare token, only the deployer’s key:

Terminal window
# Deploy the deployer, with the token it ships with and the key clients send.
DEPLOY_KEY=... CLOUDFLARE_API_TOKEN=... pnpm --filter @forge-effect/deployer ship
# Ship through it: no token on this machine.
SHIP_DEPLOY_KEY=... pnpm --filter @forge-effect/ship ship path/to/project --prebuilt --via https://ship-deployer.<account>.workers.dev
  • A Durable Object per Worker runs one ship at a time, so two deploys of a Worker never race.
  • A ship it started finishes, or is undone, even if the client goes away.
  • It keeps each Worker’s last 20 deploys (GET /deploys/<name>).
  • The key is as sensitive as the token it guards: every route but /health needs it.

Checked live on October 16: deployed, answering 401 without the key; a first ship through it, provisioned and smoke-tested from inside the deployer, its log streamed back. A second ship, a stepped rollout, failed its 10% step: the pinned probe was answered by the previous version, so the ship rolled back. Treat stepped rollouts through the deployer as not yet working.

  • Bindings it deploys: KV, D1, R2, queues (producers and consumers), service bindings, text, JSON, secrets, version metadata, AI, Durable Objects, workflows and assets. Others, and containers, fail before any request.
  • Tested against a fake Cloudflare only: routes and custom domains (the test accounts have no zones), and an assets-only Worker (one with no code, which can’t have bindings or runWorkerFirst).
  • The probe adds a module, and a version metadata binding named __SHIP_VERSION if the Worker has none. --no-verify or --no-smoke leaves the Worker exactly as it is.
  • --message writes the workers/message annotation, which Void also uses for its own deploy state, so don’t use both on one Worker.
  • @cloudflare/config says it isn’t stable yet, so ship pins it (0.26.0).