Skip to content

Plan and ship

Two commands. plan says what would happen, and changes nothing:

$ pnpm infra plan example/guestbook/app.ts
guestbook (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.dev

If 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.

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.

In order:

  1. Checks your code. The same offline checks. An error stops the ship before it reads your account (--no-check skips them, if you must).
  2. Takes the lock for the app and environment, so a second ship of the same thing waits its turn.
  3. 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.
  4. 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.
  5. 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.

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 fetch gets 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.

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:

Terminal window
CF_ACCESS_CLIENT_ID=... CF_ACCESS_CLIENT_SECRET=... pnpm infra ship app.ts

ship 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-access
failed: 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-smoke

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't
have, or a hostname another Access application already covers, stops the
ship 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` changes
the percentages:
```sh
pnpm infra ship app.ts --steps 25,100

They 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 4

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 version
13.52s ✓ deployed 50% on version 5, 50% on the previous version
14.21s ↩ rolled back to 100% on the previous version
interrupted, after undoing what it did

A 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 purpose

Each 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 7

infra check --account warns about this before you ship (exports-changed). More in Durable Objects.

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:

Terminal window
pnpm infra plan app.ts --out plan.json
# … someone reads it, approves …
pnpm infra ship app.ts --plan plan.json

The 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.

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.

  • 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.