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:
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-runwould 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, live64.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.)
The commands
Section titled “The commands”From packages/ship, with CLOUDFLARE_API_TOKEN set:
pnpm ship path/to/cloudflare.config.ts # bundles it with esbuild, and shipspnpm ship path/to/project --prebuilt # what `cf build` or Vite builtpnpm ship plan path/to/project # what a ship would do, read from the accountpnpm ship destroy path/to/project --yes # the Worker, and what ship made for itThe 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 frompublic/beside it, or--assets <dir>. - With
--prebuilt, it deploys the Build Output thatcf buildand Cloudflare’s Vite plugin write to.cloudflare/output/, ascf deploywould: 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.
What it adds to a deploy
Section titled “What it adds to a deploy”- 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 variableNAME, 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 story: the Vite app
Section titled “A story: the Vite app”A Vite app built with Cloudflare’s plugin, from its Build Output. Its first ship failed, rightly:
4.73s ✓ created Worker ship-vite, live18.69s ↩ deleted Worker ship-vite19.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 answerThe 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, live31.42s ✓ smoke test passed: 3 × GET /, answered by the new Worker31.43s ✓ shipped ship-vite: https://ship-vite.<account>.workers.devThe options
Section titled “The options”| 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 or ship?
Section titled “infra or ship?”- 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.tsor 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:
# 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
/healthneeds 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.
Good to know
Section titled “Good to know”- 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_VERSIONif the Worker has none.--no-verifyor--no-smokeleaves the Worker exactly as it is. --messagewrites theworkers/messageannotation, which Void also uses for its own deploy state, so don’t use both on one Worker.@cloudflare/configsays it isn’t stable yet, so ship pins it (0.26.0).
- Plan and ship: the same engine, with infra’s declarations.