Ship every Worker in the project to Cloudflare, then prove the version just shipped is the one answering at the address the project claims. Putting it on the internet is the walkthrough; this page is the command.
Synopsis
pithy deploy [--env <env>] [--json]Flags
| Flag | Meaning |
|---|---|
--env <env> | Target environment: dev, staging, prod, or one you declared. No default — omitted, each Worker’s top-level wrangler.jsonc stanza ships, which is not an environment at all |
--json | Machine-readable output. Default false |
There is no --worker. Deploy ships the whole set. Promoting a change from staging to production is this same command twice, with the environment named.
What it does
apps/ is the registry: every apps/<name>/ holding a wrangler.jsonc is one deployable Worker, and deploy ships them all. It runs wrangler deploy [--env <env>] in each Worker’s own directory, against that Worker’s own config, and lets wrangler own bundling, upload, bindings and routes. Output is captured and summarized rather than streamed.
A Worker that serves a front end is built first, in its own directory, through your package manager, from its manifest’s ui.build. The build carries the deploy’s environment, because the Vite plugin resolves each capability’s client-safe projection for a named environment — a build without it inlines dev values into a production bundle, silently. A failed build fails that Worker and skips its deploy: shipping a Worker whose assets are stale is worse than not shipping it.
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.
It proves what it shipped
After a Worker ships to a named environment, deploy probes that Worker’s declared domain — the domains declaration, then a route, then vars.BASE_URL — for /health, and asserts the running version matches the version id wrangler 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.
| Outcome | Means | Fails the command |
|---|---|---|
verified | The version just shipped is the version answering | No |
inconclusive | More than one version is answering (a rollout in progress), or the Worker reported no version id | No |
mismatch | One other version is answering, consistently | Yes |
unreachable | Nothing answered at all — DNS, TLS, a timeout, no route | Yes |
unreachable is a different fact from either of the first two: it is transport-level, and the detail names the address that did not answer rather than guessing at a binding. The probe retries with a short backoff. There is nothing to verify on a bare deploy, on dev, when wrangler printed no version id, or when the Worker declares no address — all four are ordinary.
It refuses an environment whose origins do not line up
With --env, before anything is built or spawned.
Every auth baseURL, OAuth callback, magic-link URL and CSRF allowed-origin is derived from an environment’s origin. When the config states none, each of them invents one — and the dangerous invention is production’s, which is how a staging deploy emails real users magic links into production.
Three shapes are refused, each naming the Worker, the environment and the edit:
- No origin at all — no
domainsdeclaration, noroutespattern, novars.BASE_URLfor that environment. Declaredomains.<env>in the Worker’spithy.config.ts, or setvars.BASE_URLin itsenv.<name>stanza.domainshas keys forstagingandprodonly, so an environment you declared yourself takes the second route. - A declared origin nothing serves —
domains(orvars.BASE_URL) names a host and noroutespattern in that stanza covers it, so the Worker would ship and answer at no address. Runpithy worker syncto write the route from the declaration. workers.devleft open beside a custom domain — the Worker answers on the domain and on<name>.<subdomain>.workers.dev, which nothing decided about. Wrangler’sworkers_devdefaults totrueand declaringroutesdoes not change it. On that originBASE_URLnames the other host and the CSRF same-origin gate refuses exactly the requests that establish who you are. Set"workers_dev": falseinenv.<name>, or"workers_dev": trueto say you meant both — a named origin is the whole requirement, not a particular value.
pithy init and pithy worker add write the route and "workers_dev": false beside every domain they declare, so a project that answered the domain question meets this already. A feature environment is exempt: it is ephemeral, has no declared domain by design, and workers.dev is how it is reached.
It refuses an environment that does not bind what its Workers declare
With --env, beside the check above and before anything is built.
An app capability’s workflows map is written into wrangler.jsonc by pithy worker sync and by nothing else, so a job declared and never synced ships with no workflows entry and no triggers.crons: the binding fails on the Worker’s first request, and the cron simply never fires — nothing errors, nothing logs, and nothing probes red.
The invariant is one sentence — what the app declares is what the stanza binds — and it is asked as one comparison of the whole table, so a missing binding, a binding nothing declares, a stale cron and a binding carrying another environment’s Workflow name are one refusal with one remedy: run pithy worker sync. A declaration that cannot be reduced to a stanza at all — a job with no className — is refused separately and sent to pithy.config.ts, because no command can write it.
It never migrates
With --env, deploy takes a best-effort count of unapplied migrations and warns when the schema is behind. A config it cannot load or a database it cannot reach yields no warning rather than a failed deploy. Promote the schema with pithy migrate first, then ship.
Every Worker deploy — success and failure — is audited as deploy/worker_deployed when the project has audit wired, recording the Worker, the version id and the verification outcome. Shipping to prod is recorded at warning severity; everything else is routine.
Credentials are CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, resolved from the account the root pithy.config.ts names, and passed explicitly to wrangler — so a local deploy authenticates the same way CI does. The account is resolved before anything else, and a pinned cloudflare.accountId that disagrees with the credentials refuses before wrangler is ever spawned. That refusal is the point: a deploy that authenticates against the wrong tenant succeeds, and says nothing.
--json
One line on stdout, after every Worker has been attempted. A failure inside the command — no project, no deployable Workers — is one {"error": …} line on stderr instead. A failed deploy is reported in the payload, and the exit code is 1.
| Key | Type | Meaning |
|---|---|---|
command | string | "deploy" |
env | string or null | The environment deployed, or null for a bare deploy of each Worker’s top-level stanza |
pendingMigrations | number or null | Unapplied migrations for the target environment. null with no --env, and null when the count could not be taken — deploy never fails over it |
workers | object[] | One entry per Worker, in discovery order. Every Worker is attempted |
workers[].name | string | The Worker’s name |
workers[].ok | boolean | Whether wrangler deploy succeeded for this Worker |
workers[].versionId | string, optional | The deployed version id, when wrangler’s output carried one |
workers[].url | string, optional | The public URL wrangler printed, when it printed one |
workers[].built | boolean, optional | Whether this Worker’s UI build ran and succeeded. Absent when the Worker declares no ui block — so false means the build is what failed and the deploy never ran |
workers[].error | string, optional | The failure reason. Present only when ok is false |
workers[].verification | string, optional | What probing the declared domain concluded: "verified", "mismatch", "inconclusive" or "unreachable". Absent when there was nothing to check |
workers[].verificationDetail | string, optional | The one-line explanation behind verification |
Exit code. 0 when every Worker’s ok is true and no Worker’s verification is mismatch or unreachable; 1 otherwise. The line is printed either way, so a CI step can read the payload and still gate on the status.
Errors
No pithy.config.ts here.Run it from a Pithy project.No deployable workers here.Nothing underapps/carries awrangler.jsonc. Runpithy worker add <name>.- A Cloudflare account mismatch. The project pins one account and the resolved credentials belong to another. Refused before wrangler is spawned, naming both ids and which source supplied the wrong one — a file is a local misconfiguration, the environment is a CI job pointed at the wrong tenant.
- An environment whose named and served origins do not line up. Refused before anything is built, naming the Worker, the environment and the one edit that answers it.
- An environment that does not bind what its Workers declare. Refused before anything is built, naming both sides of the comparison and
pithy worker sync. - A binding with no resource behind it. Refused, naming
pithy provision --env <name> --yes. - A Worker’s build or deploy failing. Reported per Worker on the row rather than thrown. A build failure and a deploy failure read differently, because they have different fixes. wrangler’s exit code and stderr are the
errorstring. --envis validated at the flag when one is given.productionis answered withprod.
Examples
pithy deploy
pithy migrate --env prod --json
pithy deploy --env prod --json- name: Deploy workers
run: pithy deploy --env prod --json
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}A batch where one Worker shipped and the other’s build failed:
{"command":"deploy","env":"prod","pendingMigrations":0,"workers":[{"name":"acme-api","ok":true,"versionId":"<version-id>","url":"https://acme-prod-api.example.workers.dev","verification":"verified","verificationDetail":"https://api.example.com is serving the version just deployed."},{"name":"acme-web","ok":false,"built":false,"error":"vite build failed."}]}The two environment variables in the CI snippet are the names of secrets your provider holds. No value appears there, and no payload from this command can carry one.