---
title: "Workers Builds and preview URLs"
sidebar:
  order: 6
---

Issue: [#11](https://github.com/patronage/launch/issues/11). Builds on the `cf` deploy pattern from [Spike 1](/research/vinext-on-workers).

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

1. One time for the repo: install the Cloudflare GitHub app on `patronage/launch` from the dashboard (Workers & Pages, Create, connect GitHub). This is the only dashboard step.
2. Deploy once from a machine, so the Worker and its KV namespace exist: `pnpm run deploy`.
3. Set the runtime secrets that `cloudflare.config.ts` declares with `pnpm run secrets`, values from the environment. Never add them as build variables.
4. 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'`).
5. `BUILD_TOKEN_UUID=… GITHUB_OWNER_ID=… GITHUB_REPO_ID=… pnpm run builds:configure`.
6. Push a branch and check that its Preview URL comes up, then merge and check production.
7. 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 gets `400 [12002] Invalid request body`.
- Each Preview keeps its own copy of the build settings, taken when the Preview is created. Changing `previews_base_config` only affects new Previews; send the update again with `--patch-existing-previews true` to change the ones that already exist.
- Watch paths decide whether a push builds at all. A change to only the root `.nvmrc` or `package.json` does not start one.
- The first Preview build passed (`pnpm -w run verify` plus `cf previews deploy`), so Workers Builds accepts `cf previews deploy` as the preview command.
- The build image has no PHP, so the full `pnpm verify` fails 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 deploy` refuses 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 in `cloudflare.config.ts` instead.
- That Preview came back with no URL, because the Worker, created in the dashboard, had workers.dev turned off. `cloudflare.config.ts` now sets `workersDev` and `previewUrls`, 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 deploy` runs with no account id or token set as build variables.
- `cf deploy` without `--secrets-file` keeps 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-wordpress` binds 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/payload` sets them with `pnpm run secrets --preview-base`. Until a Worker has them, `apps/web-wordpress` Previews render, but revalidation and draft mode fail. A Payload Preview cannot start without `PAYLOAD_SECRET` at all, which is why its `builds:configure` refuses to turn branch builds on until the base secrets exist. The `apps/web-wordpress` Previews built so far are not full CMS preview environments.
- Putting Previews behind Access (step 7 above).
