Skip to content

CI-time Cloudflare resource IDs

ADR-2026-0002: CI-time Cloudflare resource IDs

Section titled “ADR-2026-0002: CI-time Cloudflare resource IDs”

Citizenry deploys five applications to Cloudflare - three Workers (api, admin-api, mcp), one migration Worker (migrator), and two SvelteKit Pages projects (web, admin-web). Three of the four Workers share a single D1 database (citizenry-vault), and migrator also touches a second D1 (citizenry-identity). The same wrangler.toml files are consumed for local development and for production deploys.

Two pressures shape the deploy story:

  1. Adopter ergonomics. A fresh fork should reach a running deployment with a small, well-defined number of steps. The current docs/deploy.md flow is “fork → set repository secrets → push to main.”
  2. Multi-Worker resource sharing. The same D1 UUID must be wired into four wrangler.toml files, and migrations must run against the same database the runtime Workers bind to.

Cloudflare ships several deployment paths that look like they could simplify this - the “Deploy to Cloudflare” button, the dashboard’s “Connect to Git” flow on Workers Builds, and automatic resource provisioning in Wrangler 4.45+. We need to choose one and document why the others were not chosen, because the trade-offs are non-obvious and the limits are spread across separate documentation pages and open bug reports.

  • Multi-Worker shared D1. Four wrangler.toml files must agree on one UUID for the vault D1 and a second UUID for the identity D1. Cloudflare D1 databases are identified by UUID, not by name; a name is not a unique key on the account.
  • Pages support. apps/web and apps/admin-web deploy as Cloudflare Pages projects and must ship in the same flow as the Workers.
  • Idempotent migrations. wrangler d1 migrations apply --remote must run against the resolved D1 UUID on every deploy, including the first.
  • Forkability. Anyone forking the public repo must be able to deploy without coordinating with us, and the committed configuration must not embed our UUIDs.
  • Operator visibility. Failure modes during deploy must surface in a single readable place - logs, retry, diff.
  1. CF “Deploy to Cloudflare” button. Single-click fork-and-deploy via OAuth; relies on Wrangler auto-provisioning.
  2. CF dashboard “Connect to Git” (Workers Builds). Per-Worker setup in the dashboard; CF runs wrangler deploy on push.
  3. GitHub Actions + CI-time wrangler config rendering (chosen). Workflow calls the CF REST API to list-or-create D1 databases, patches placeholder IDs in wrangler.toml within the CI workspace, then runs wrangler deploy for each app.
  4. Provision Worker. A bootstrap Worker installed via the Deploy button that calls the CF API to spawn the rest of the stack and self-destruct.
Deploy buttonConnect to GitGitHub Actions (chosen)Provision Worker
Multi-Worker shared D1weak - auto-provision is per-Worker, no shared-name guaranteeweak - IDs not written back; account uniqueness is by UUID, not name✓ same UUID patched into all four configs in one run✓ if the bootstrap script enumerates the other Workers
Pages projects supported✗ docs: “Pages applications are unsupported”✗ Pages is a separate creation flow✓ workflow runs wrangler pages deploy for both✗ Pages is not addressable from a Worker
ID write-back to repo✓ JSON/JSONC only - broken for TOML (workers-sdk#13632)✗ never written back, dashboard-onlyn/a - repo keeps placeholders, CI re-resolves every runn/a
Token scope for D1 ops✗ Workers Builds token has no D1 scope✗ same✓ operator-supplied token spans all needed scopes✗ same as Workers Builds
d1 migrations apply --remotebroken - no resolved ID for TOMLbroken - placeholder-only repo✓ runs against the rendered configdepends on bundled tooling
Operator UX on failuredashboard logs per Workerdashboard logs per Workersingle Actions run UI with diff and rerunwrangler tail
Required adopter inputOAuth + per-Worker clickOAuth + per-Worker clickthree GitHub secretsOAuth + secret
Forkable without leaking IDs

We deploy via GitHub Actions. The committed wrangler.toml files carry the literal placeholder database_id = "local-dev-placeholder" for both D1 bindings. The workflow performs three steps before any wrangler deploy:

  1. scripts/ci/provision.mjs calls the Cloudflare REST API directly to list-or-create the two D1 databases by name. UUIDs are emitted to $GITHUB_OUTPUT.
  2. scripts/ci/render-wrangler.mjs rewrites apps/{api,admin-api,mcp,migrator}/wrangler.toml in the CI workspace, substituting the placeholder with the resolved UUID. The substitution is anchored on binding = "DB_VAULT" and binding = "DB_IDENTITY" so unrelated fields are not touched.
  3. The patched workspace is consumed by wrangler deploy for each Worker, wrangler d1 migrations apply --remote against the migrator config, and wrangler pages deploy for the two SvelteKit projects.

The patched files exist only in the ephemeral CI workspace and are never committed back. The repo stays in placeholder state, which makes it trivially forkable.

  • Multi-Worker shared D1 works correctly: all four wrangler.toml files receive the same UUID inside one workflow run.
  • Pages projects deploy in the same workflow alongside Workers.
  • Migrations always run against the same UUID the runtime Workers bind to, because rendering happens before both the deploy and the migration step.
  • Forks inherit a clean repo. No upstream UUID leaks into git history; every fork rebinds to its own Cloudflare account on first run.
  • A single GitHub Actions run UI is the source of truth for deploy outcomes: per-step logs, diffs, reruns, and required-secret checks.
  • Token scope is owned by the operator’s CF token and can be expanded independently of any platform default.
  • Operators must create a scoped Cloudflare API token by hand and paste it as a GitHub repository secret. This step is irreducible until Cloudflare supports GitHub OIDC for the Workers / D1 / Pages API surfaces (tracked at cloudflare/wrangler-action#402, open at the time of writing).
  • The committed wrangler.toml files are not directly deployable with wrangler deploy --remote from a fresh checkout; they require running provision.mjs + render-wrangler.mjs first. Local wrangler dev is unaffected because miniflare ignores the placeholder database_id.
  • Two small Node scripts (provision.mjs, render-wrangler.mjs) join the CI surface and must be maintained as the binding shape evolves.
  • provision.mjs talks to the Cloudflare REST API directly rather than through wrangler. This sidesteps cloudflare/workers-sdk#13632 (TOML write-back) and keeps idempotency under our control, at the cost of restating endpoint paths in source.

Already landed at the time of this ADR:

  • .github/workflows/deploy.yml - workflow with provision → render → deploy → migrate → pages steps.
  • scripts/ci/provision.mjs - list-or-create the two D1 databases.
  • scripts/ci/render-wrangler.mjs - patch placeholders in the CI workspace.
  • scripts/ci/migrate-identity.sh, scripts/ci/push-secrets.sh, scripts/ci/deploy-pages.sh - companion scripts for Postgres migrations, secret rotation, and Pages deploys.

Adopter-facing documentation is docs/deploy.md.

  • Re-evaluate when either of the following lands upstream:
    • cloudflare/wrangler-action#402 - OIDC support. Closing this would let us drop CLOUDFLARE_API_TOKEN from GitHub secrets and authenticate via short-lived tokens minted from the workflow’s OIDC claim. The workflow shape stays the same; only the auth step changes.
    • cloudflare/workers-sdk#13632 - TOML write-back support. Closing this does not eliminate provision.mjs (we still need the API call to share UUIDs across four configs) but would let us simplify the rendering step in single-Worker subprojects.
  • If a future Worker is added that does not share D1 with the existing four, prefer adding it as a separate workflow job over inflating render-wrangler.mjs with conditional binding logic.