Put it on the internet

Up to here nothing has touched Cloudflare. Your Worker has been running through Miniflare, against a SQLite file under .wrangler/state, with no account in the picture at all.

This page is where that changes. Four commands, in an order that matters, and one of them will refuse you if you get the order wrong.

The order, first

Provision, then migrate, then deploy. They are separate, gated steps, and each one refuses to do the next one’s job.

  • pithy provision creates the account resources and writes their ids into your config. It does not ship code.
  • pithy migrate promotes your schema. It never seeds and never deploys.
  • pithy deploy ships Workers. It never migrates and it never provisions — it warns when the target schema is behind, and it refuses outright when a binding has no resource behind it, naming the command that fixes it.

A deploy that silently created account resources would be hard to review, and these are exactly the resources worth reviewing.

1. A Cloudflare account, and one token

Everything starts from one credential you make by hand: a bootstrap API token, which pithy init offered to record. If you skipped that, pithy doctor has been naming the missing credentials ever since.

The token needs API Tokens → Edit at account scope, at minimum, so the CLI can mint every other token for you. Cloudflare only lets a token create another token whose permissions it already holds, so the bootstrap token must also hold everything it will delegate — the union of what your composed capabilities need CI to do. Cloudflare API tokens works through the permission list; the short version is that you make one broad token once, and the CLI mints narrow ones from it forever after.

It lands in <config>/cloudflare.json — mode 0600, in a 0700 directory, outside every checkout. Nothing secret goes in your repository, and CI supplies the same two values as environment variables with no config file at all.

2. Say where the Worker answers

An environment needs an origin before it is deployed, and Pithy refuses a deploy without one. That is not fussiness. Every auth baseURL, OAuth callback, magic-link URL and CSRF allowed-origin is derived from an environment’s origin — so an environment that states none has each of them invent one, and the dangerous invention is production’s. That is how a staging deploy emails real users magic links pointing into production.

Declare it once, in apps/api/pithy.config.ts:

const DOMAINS = {
  staging: { pattern: "staging.api.example.com", zone: "example.com" },
  prod: { pattern: "api.example.com", zone: "example.com" },
};

The routes entry and vars.BASE_URL in wrangler.jsonc are generated from that. pithy init and pithy worker add write them when you answer the domain question; pithy worker sync writes them for a block you added by hand.

dev is absent on purpose — a local run answers on the port this checkout pinned.

3. Provision

pithy provision --env staging --yes

--yes confirms that this creates real Cloudflare resources, and it is required for every declared environment.

What it does, in order: it resolves the naming scope, creates one resource per binding name across the union of every Worker’s capabilities, writes the ids into each Worker’s env.staging stanza, writes the Secrets Store bindings, mints the secrets that have no decision in them, retargets service bindings at this environment’s copy of the callee, and migrates.

Three properties are worth holding on to.

One resource per binding name. Two Workers that both declare DB share one database. A Worker that wants its own declares a different binding. The name is the sharing decision.

It adopts rather than duplicates. Every resource is matched by name before it is created, so a re-run is a no-op, and a database you made by hand under the right name is taken up rather than shadowed by a second one.

It states what it wrote, and what happens to it. Wrote 3 ids into apps/api/wrangler.jsonc. Commit them. Those ids are source — long-lived values a human reviews in a pull request — and the run says so rather than leaving you to infer it.

The secrets it cannot create

Some secrets are sealed under a master key that lives inside an environment’s secrets manager Worker. Only that manager can write one, and provision runs before the managers are necessarily deployed. So it creates none of them and says which:

auth-session-secret, email-link-signing-key: not created here — they need a deployed manager.
Run pithy secrets provision to create them.

Run that. It deploys a manager into each environment the project declares and creates what was pending. A capability that reads one of those secrets fails at its first request without it, so this is not an optional step — it is a stated shortfall with a named remedy, which is better than Provisioned staging. Migrated. on an environment that cannot serve a request.

4. Migrate

pithy migrate --env staging

Same registry, same ordering, same per-database runs as dev. Only the driver differs: dev goes through Miniflare, and a deployed environment executes over the D1 REST API against the database that environment’s stanza names. You pass no ids.

Every database in the run is claimed for this project before any of them is written to, so a database another project owns aborts the run rather than being discovered halfway through.

5. Deploy

pithy deploy --env staging

apps/ is the registry: every apps/<name>/ holding a wrangler.jsonc is one deployable Worker, and deploy ships them all. There is no --worker flag and no hand-maintained list. One Worker’s failure does not abort the batch — every Worker is attempted and reported, and the command exits non-zero if any of them failed.

A Worker that serves a front end is built first, carrying the deploy’s environment, because the Vite plugin resolves each capability’s client-safe projection for a named environment. A build without it would inline dev values into a production bundle, silently.

It proves what it shipped

This is the part most deploy tools do not do. After a Worker ships, deploy probes that Worker’s declared domain for /health and asserts that the version answering matches the version id wrangler just reported.

Not the URL wrangler printed, which under gradual deployments may be a version-scoped preview. And not a liveness check, because the old version answering happily is precisely the failure worth catching — a deploy that landed somewhere else while the declared domain kept serving what was already on it.

Four outcomes, and two of them fail the command:

OutcomeWhat it means
verifiedThe version you shipped is the version answering
inconclusiveA rollout is in progress, or the Worker reports no version id
mismatchOne other version is answering, consistently. Fails
unreachableNothing answered at all — DNS, TLS, a timeout, no route. Fails

Then production

The same four commands with --env prod, and one extra gate. A production environment — the built-in prod and production, plus anything you name in seed.productionEnvironments — needs the exact phrase as well as --yes:

pithy provision --env prod --yes --confirm "yes, i really want to provision prod"

The phrase names its environment, so one typed for staging cannot be pasted into a command targeting production. Interactively the CLI asks for it. Under --json it must arrive by flag, which means a headless production provision happens only when a person wrote the phrase into the pipeline on purpose.

In CI

Every one of these runs headless. --json everywhere, exit codes that mean something, and no interactive login — the two Cloudflare environment variables are the whole credential story:

- name: Promote schema
  run: pithy migrate --env prod --json
  env:
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

- name: Deploy workers
  run: pithy deploy --env prod --json
  env:
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

The bootstrap token works there, but it is not what CI should run under. pithy token mint ci-system --env prod produces a least-privilege credential named <project>-<env>-ci-system, scoped to exactly what your composed capabilities need CI to do, and writes the value to <config>/<project>/tokens.json for you to copy into your CI provider’s secrets. No Pithy command reads that file — it is a handoff to a person, because CI cannot read either secret store and the credential has to cross the gap somehow.

What is not here

There is no teardown for a declared environment. pithy feature destroy reverses a branch’s environment, because a branch’s environment is disposable. Staging and production are not, and the one-word difference between them is not a difference a flag should carry. Delete them in Cloudflare, by hand, on purpose.

ESC