Plan and ship
Two commands. plan says what would happen, and changes nothing:
$ pnpm infra plan example/guestbook/app.tsguestbook (production)+ d1 book: guestbook-book~ the D1 database guestbook-book: the next ship applies migration 1: migration-pending+ Worker guestbook-guestbook: new, live at once~ rollout: 100%ship makes it so:
$ pnpm infra ship example/guestbook/app.ts 1.28s shipping guestbook-guestbook 1.29s ✓ 739.1 KB of modules 2.01s ✓ created D1 database guestbook-book 2.47s ✓ applied migration 1 to D1 database guestbook-book 3.68s ✓ created Worker guestbook-guestbook, live 8.16s ✓ smoke test passed: 3 × GET /, answered by the new Worker 8.51s ✓ shipped guestbook-guestbook: https://guestbook-guestbook.<you>.workers.devIf you followed Build your first app, you’ve seen both. This page is what’s going on underneath, and the knobs.
Both need a Cloudflare API token in CLOUDFLARE_API_TOKEN, and
CLOUDFLARE_ACCOUNT_ID if the token reaches more than one account.
Reading a plan
Section titled “Reading a plan”A plan reads your account and forge-effect’s state, and says, a line each:
| Means | |
|---|---|
+ |
made by this ship |
= |
already there, the app’s, left as it is |
~ |
changed: a new version, a migration, a rollout, an orphan |
! |
a problem a ship stops on, such as a name that isn’t yours |
? |
a contract change the check can’t judge: look yourself |
→ / ← |
what this app uses of other apps, and who uses this one |
The second time round, the plan for the guestbook reads:
guestbook (production)= d1 book: guestbook-book~ Worker guestbook-guestbook: a new version; now 100% on 3bd046c7~ rollout: 10% → 50% → 100%The database is already the app’s. The Worker gets a new version, and since it serves real traffic now, it rolls out in steps.
A plan runs infra check’s offline checks first,
and prints what they find above it.
What a ship does
Section titled “What a ship does”In order:
- Checks your code. The same offline checks. An error stops the ship
before it reads your account (
--no-checkskips them, if you must). - Takes the lock for the app and environment, so a second ship of the same thing waits its turn.
- Checks the account, before it makes anything: a resource name
that isn’t the app’s (
name-taken), a Secrets Store secret with no value, a contract change that breaks callers, a route in no zone or serving another Worker. Any of these stop it, with nothing changed. - Ships each Worker, each after the Workers it calls:
- makes the resources it doesn’t have yet,
- applies the databases’ new migrations,
- uploads its files, only those Cloudflare lacks,
- uploads the new version with no traffic, and smoke-tests it,
- moves traffic to it a step at a time, smoke-testing at each step,
- attaches its routes and custom domains, once it’s live.
- Records what it did in the state: what it made, the versions now running, the contracts deployed. Then it detaches the routes and domains you stopped declaring.
A Worker’s first ship goes live at once, as there’s no earlier version to keep the traffic on. If it fails, it’s deleted.
Smoke tests that check who answered
Section titled “Smoke tests that check who answered”After each step, ship calls the Worker (GET /) until three requests in a
row succeed. That alone would prove little: mid-rollout, the old
version could answer, and the test would pass.
So ship puts a tiny module in front of your Worker’s code, which answers one unguessable path with the version that’s running. Each step’s test pins its requests to the new version, and passes only if the new version is the one that answered. That’s the “answered by the new Worker” in the log.
- In its first seconds, a new URL can answer with Cloudflare’s own 404 or error page. Those are waited out; a failure from your code fails the test at once.
- A Worker with no
fetchgets one, for the probe alone, and its tests count no requests. - A Worker that hosts Durable Objects has no preview URLs, so its new version goes into the deployment at 0% first, and is tested through the Worker’s URL. At each step above 0%, the probe also starts new objects until one runs the new version, so a version whose objects fail to start fails its step.
Behind Cloudflare Access
Section titled “Behind Cloudflare Access”If Cloudflare Access guards your Workers (say, one Access application over
your whole *.<subdomain>.workers.dev), every new Worker is private from
its first request, and so are ship’s smoke tests: Access sends them to its
login page. Give ship a service token that a Service Auth policy allows:
CF_ACCESS_CLIENT_ID=... CF_ACCESS_CLIENT_SECRET=... pnpm infra ship app.tsship sends the two as headers on every smoke-test request and every probe. Forget them, and the ship stops at once and undoes itself, saying why:
2.39s ✓ created Worker ship-access, live 3.84s ↩ deleted Worker ship-accessfailed: Cloudflare Access guards https://ship-access.<account>.workers.dev/, and asked the smoke test to log in: set CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET to a service token a Service Auth policy of its Access application allows, or ship with --no-smokeWho may reach it
Section titled “Who may reach it”A Worker says who may reach its workers.dev URL and custom domains, and each ship keeps the Access application that does it:
// Anyone: Access bypassed. Its previews stay private.Worker.make('docs', { domains: ['example.com'], access: Access.public }).bind(Site);// Only those your account's reusable Access policies of these names allow.Worker.make('admin', { fetch, access: Access.only('team (one-time PIN)') });Take access out, and the next ship removes the application: your
account’s own Access decides again. Behind Access, the code can ask who’s
asking, a person or a service token, from the token Access signed:
class Team extends Access.Team<Team>()('myteam.cloudflareaccess.com') {}const who = yield* Access.identity(Team); // { _tag: 'User', email, ... } or { _tag: 'ServiceToken', clientId }Worker.make('admin', { fetch, access: Access.only('team (one-time PIN)') }).bind(Team);``` A policy name your account doesn'thave, or a hostname another Access application already covers, stops theship before anything is made, and `infra check --account` says so first.
## Steps, and changing them
The default is 10% → 50% → 100%, five seconds a step. `--steps` changesthe percentages:
```shpnpm infra ship app.ts --steps 25,100They end at 100. Here are the steps as ship logs them, from ship’s own
fixture (with --no-smoke):
1.83s ✓ uploaded version 4, with no traffic 2.80s ✓ deployed 10% on version 4, 90% on the previous version 5.30s ✓ deployed 50% on version 4, 50% on the previous version 7.75s ✓ deployed 100% on version 4Undone, on failure or Ctrl-C
Section titled “Undone, on failure or Ctrl-C”If anything fails, or you press Ctrl-C, the ship undoes what it did, in reverse:
- traffic goes back to the previous deployment, exactly as it was (unless someone else has deployed since),
- a Worker that didn’t exist before is deleted,
- resources this ship made are deleted,
- routes it took over go back to the Workers they served, and domains it added are removed.
Resources that existed before are never touched. And because every Worker of the app ships in one scope, a failure in the third Worker undoes the first two as well.
Ctrl-C halfway through a rollout (ship’s own fixture):
2.53s ✓ deployed 10% on version 5, 90% on the previous version13.52s ✓ deployed 50% on version 5, 50% on the previous version14.21s ↩ rolled back to 100% on the previous version
interrupted, after undoing what it didA broken version never gets traffic: its test on its own preview URL fails first.
2.05s ✓ uploaded version 7, with no traffic
failed: smoke test of https://f9cc274d-ship-hello.<account>.workers.dev/ failed: 500 this version is broken, on purposeEach failure, with its reason, goes into the app’s history too
(infra state).
--atomic: when a version can’t share traffic
Section titled “--atomic: when a version can’t share traffic”Cloudflare can’t split traffic between two versions with different Durable Object classes. So a version that adds, renames or removes one can’t be rolled out in steps, and the ship stops and says so (ship’s own fixture):
stopped: this version adds Tally (durable-object, sqlite), and Cloudflare can't split traffic between versions with different exports; ship it with --atomic to deploy it at 100% at once, rolled back if its smoke test fails--atomic deploys it at 100% at once, still smoke-tested, still rolled
back if the test fails:
2.86s ✓ uploaded version 7, with no traffic 3.21s version changes exports (+Tally (durable-object, sqlite)), so it goes out at 100% at once 3.73s ✓ deployed 100% on version 7infra check --account warns about this before you ship
(exports-changed). More in Durable Objects.
Saved plans: ship what was reviewed
Section titled “Saved plans: ship what was reviewed”In a team, or a pipeline, you may want a plan reviewed and then exactly that plan shipped, not whatever’s true a few minutes later. Save it:
pnpm infra plan app.ts --out plan.json# … someone reads it, approves …pnpm infra ship app.ts --plan plan.jsonThe saved plan holds what the plan read: each Worker’s code and settings
as it would deploy (hashed), the Workers’ current deployments, the
resources as they resolved, and the contracts the state records. The ship
checks all of it again, under the lock. If anything changed (a commit, a
deploy from elsewhere, a resource deleted), it ships nothing and says
what changed, as plan-stale.
A saved plan ships with the options it was made with, so --steps,
--atomic and --no-smoke go on the plan, not the ship. It’s for
one environment, too: the one it was planned for.
Checked live: a saved plan shipped, and the same plan, tried again, was refused.
The flags
Section titled “The flags”infra ship |
|
|---|---|
--env <name> |
another environment; production by default |
--steps 10,50,100 |
the rollout’s percentages |
--no-smoke |
no smoke tests |
--atomic |
100% at once, for a version that changes Durable Object classes |
--allow-breaking |
ship a contract change that breaks deployed callers (Workers calling Workers) |
--take-routes |
take routes and domains that serve other Workers (Websites) |
--no-check |
skip the offline checks |
--plan <file> |
ship a saved plan |
infra plan takes --env, --steps, --atomic and --no-smoke (as
the plan’s options), --json for a report, and
--out <file> to save it.
Good to know
Section titled “Good to know”- A ship takes seconds per step: about eight seconds for the guestbook’s first, with its smoke test. A smoke test can wait longer while a brand new workers.dev URL warms up; ship’s own first live test waited out a minute of it.
- An undo that fails even after retries is logged with
!before the ship ends, so you know what to look at. - infra ships with ship, which you can also use alone, for a Worker that isn’t declared in Effect.