You need: a zone on your Cloudflare account.
Declare it once
pithy.config.ts is where the declaration lives, pithy worker sync is what writes it into the stanza, and deploying to staging and production is what refuses when the two disagree.
const DOMAINS = {
staging: { pattern: "staging.api.example.com", zone: "example.com" },
prod: { pattern: "api.example.com", zone: "example.com" },
};
export const PUBLIC_ORIGIN = originFor(compositionEnvironment(), DOMAINS);
const config = { domains: DOMAINS, capabilities: [ … ], app };A bare hostname, not a URL — that is what the route matcher takes. The zone is separate because Cloudflare needs it and it is not always derivable from the hostname.
dev is absent on purpose: a local run answers on the port this checkout pinned.
Everything derives from it
The route entry and the base URL variable are generated from that declaration — and so is every capability’s notion of where this Worker is.
Every capability that needs a public origin asks the same question. Auth builds OAuth callbacks, magic-link URLs and the CSRF allowed-origin. Email builds tracking and unsubscribe links. Payments’ return URLs decide where a browser lands after checkout.
Write a URL into any one of them and you have written one environment’s origin into all of them. Three of them, one mistake, three separate discoveries: staging mails real users links into production, an unsubscribe from a staging test unsubscribes them in production, and a staging payer lands in production on an account that bought nothing.
Writing the route
pithy init and pithy worker add write it when you answer the domain question.
For a block you added by hand:
pithy worker sync --worker apiThat is the only non-interactive way to get the route written. Before it, the route came from the prompt and nowhere else — so a block added by hand, which is the documented way to add an environment, declared an address nothing served: doctor reported it healthy, deploy shipped it, and the Worker answered on nothing.
Running it twice changes nothing.
Turn off the public subdomain
This is the one that catches people, and the failure is specific.
Cloudflare’s default leaves your Worker answering on its public subdomain, and declaring a route does not change it. So the Worker answers on both your domain and that one.
On that other origin, your base URL names the custom host — so the same-origin gate refuses exactly the requests that establish who somebody is. Sign-in fails there and works on the real domain, which is a confusing afternoon.
Set the flag to false in that environment’s stanza, or explicitly to true to say you meant both. A named origin is the whole requirement rather than a particular value.
pithy init and pithy worker add write it beside every domain they declare, so a project that answered the domain question never meets this.
Deploy refuses three shapes
Before anything is built:
No origin at all — no declaration, no route, no variable. Legitimate on day one, which is why doctor reports it without failing and deploy is what refuses it, at the moment it stops being hypothetical.
An origin nothing routes — the shape the subdomain flag produces on a Worker whose route was never written.
The public subdomain left open beside a custom domain.
Each names the Worker, the environment and the one edit.
Deploy proves the address
After shipping, it probes that Worker’s declared domain for the health route and asserts the version answering matches what was just deployed.
Not the URL wrangler printed, which under gradual deployments may be a version-scoped preview.
Nothing answering at all is reported as unreachable and fails the command — a different fact from a version mismatch, and the detail names the address that did not answer rather than guessing at a binding.
A feature environment is exempt
It is ephemeral, has no declared domain by design, and the public subdomain is how it is reached.
Both refusals skip it, deliberately.
When a domain moves
Update the declaration, sync, deploy — and re-point any control-plane connection, because it stored the address at connect time:
pithy dashboard connect --env prod --update --worker-url https://api.example.comA connection whose ping fails surfaces as needs reconnecting rather than as a silent dead link.
And the OAuth providers. Each holds a registered redirect URI on its own console, and a moved host means a mismatch there — which is worth doing at the same time rather than discovering on the next social sign-in.
Check it worked
- The route exists in the stanza and the base URL variable matches
- The public subdomain is off, or explicitly on
- Deploy reports verified
- A magic link from staging points at staging
pithy envshows the right base URL per environment