Workers Builds and preview URLs
Issue: #11. Builds on the cf deploy pattern from Spike 1.
Status
Live. Workers Builds builds and deploys apps/web-wordpress, apps/payload and apps/docs-site from main, and branch Previews build for apps/web-wordpress (apps/payload has branch builds off by default). What the runs settled and what is still open is at the end.
How it works
There are no GitHub Actions in this repo. Each deployable app is its own Worker with its own Workers Builds configuration:
| Setting | Value |
|---|---|
| Root directory | the app folder, e.g. apps/web-wordpress |
| Watch paths | <app>/*, packages/*, pnpm-lock.yaml, pnpm-workspace.yaml, so a change to another app does not rebuild this one |
| Build command | Frozen install and lint for the workspace, then typecheck and tests for the app and the packages it depends on (pnpm --filter "<app>..."). A failure stops the deploy. The full pnpm verify is not used because it also runs the WordPress installs’ PHP checks, and the build image has no PHP |
| Production deploy command | pnpm run deploy, on main |
| Preview deploy command | pnpm run deploy --preview, on every other branch |
| Build variables | NODE_VERSION from .nvmrc, PNPM_VERSION from the root packageManager, SKIP_DEPENDENCY_INSTALL=1 |
SKIP_DEPENDENCY_INSTALL is there because the app folder has no lockfile of its own. Without it, the build image would pick a package manager by looking for a lockfile in the root directory and find none. The build command’s pnpm -w run deps:verify does the install instead, from the workspace root.
scripts/workers-builds.mjs creates this configuration through the Workers Builds API (cf builds workers create). It reads the Worker name from cloudflare.config.ts and the Node and pnpm versions from the repo, so the only inputs are ids.
Preview deploys use Worker Previews (cf previews deploy). Each branch gets its own Preview under the same Worker, at <branch>-<worker>.<subdomain>.workers.dev, and that URL always serves the branch’s latest push. cloudflare.config.ts can tell a Preview build from a production build through isPreview on the config context, if a binding ever needs to differ.
First run, per app
- One time for the repo: install the Cloudflare GitHub app on
patronage/launchfrom the dashboard (Workers & Pages, Create, connect GitHub). This is the only dashboard step. - Deploy once from a machine, so the Worker and its KV namespace exist:
pnpm run deploy. - Set the runtime secrets that
cloudflare.config.tsdeclares withpnpm run secrets, values from the environment. Never add them as build variables. - Find the build token with
cf builds tokens list, and the GitHub owner and repo ids (gh api repos/patronage/launch --jq '.owner.id, .id'). BUILD_TOKEN_UUID=… GITHUB_OWNER_ID=… GITHUB_REPO_ID=… pnpm run builds:configure.- Push a branch and check that its Preview URL comes up, then merge and check production.
- Put Previews behind Cloudflare Access. Preview URLs are public by default; Access can cover one Worker’s Previews or a hostname pattern. Scripting that belongs with the Access spike; until then, add the Access application by hand and note it in the issue.
Custom domains
Set WORKER_DOMAIN as a build variable (for example launch-wordpress.<zone>), and the next production deploy attaches it as the Worker’s custom domain. WORKER_DOMAIN_PREVIEWS=true also serves each Preview at <preview>.<domain>. Those hostnames sit two levels below the zone, so check the certificate first: a Free plan’s Universal SSL covers only one level. Without it, Previews still get <preview>-<worker>.<subdomain>.workers.dev.
Learned on the first run
- The Builds API takes each build variable as
{ "value": "…", "is_secret": false }, not a bare string. A bare string gets400 [12002] Invalid request body. - Each Preview keeps its own copy of the build settings, taken when the Preview is created. Changing
previews_base_configonly affects new Previews; send the update again with--patch-existing-previews trueto change the ones that already exist. - Watch paths decide whether a push builds at all. A change to only the root
.nvmrcorpackage.jsondoes not start one. - The first Preview build passed (
pnpm -w run verifypluscf previews deploy), so Workers Builds acceptscf previews deployas the preview command. - The build image has no PHP, so the full
pnpm verifyfails on the WordPress installs’ checks. Build commands run the frozen install and lint for the workspace, then typecheck and test only the app and its dependencies. cf deployrefuses to run while a declared secret is unset, and its automatic provisioning fails with[10014]when a KV namespace of the same title exists but no deployed version binds it. That happens after a failed first deploy; bind the existing namespace by id incloudflare.config.tsinstead.- That Preview came back with no URL, because the Worker, created in the dashboard, had workers.dev turned off.
cloudflare.config.tsnow setsworkersDevandpreviewUrls, and the next production deploy applies them.
Settled by the production runs
All three Workers (launch-vinext-wordpress, launch-payload, launch-docs) deploy from main through Workers Builds as of 2026-10-30.
- Workers Builds gives the deploy command working Cloudflare credentials:
cf deployruns with no account id or token set as build variables. cf deploywithout--secrets-filekeeps the secrets already on the Worker. Production builds deploy with no secret values in the build environment.- Previews use their own KV namespace:
apps/web-wordpressbinds the production namespace by id only when the build is not a Preview.
Still open:
- How Previews get secrets. A new Preview copies the Worker’s Previews base secrets when it is created;
apps/payloadsets them withpnpm run secrets --preview-base. Until a Worker has them,apps/web-wordpressPreviews render, but revalidation and draft mode fail. A Payload Preview cannot start withoutPAYLOAD_SECRETat all, which is why itsbuilds:configurerefuses to turn branch builds on until the base secrets exist. Theapps/web-wordpressPreviews built so far are not full CMS preview environments. - Putting Previews behind Access (step 7 above).