# Cloudflare Workers Builds Keyless Deploy

> Deploy Cloudflare Workers from GitHub with ZERO Cloudflare credentials stored in GitHub (no CLOUDFLARE_API_TOKEN in Actions secrets), using Workers Builds — Cloudflare's git-connected CI/CD. Use when setting up or migrating a Workers project so that an autonomous agent pipeline never holds a Cloudflare secret, when asked "can we deploy without a CF API token in CI", or when driving the Cloudflare dashboard connect ceremony (by hand or with a browser agent). Covers the traps that cost hours — the custom token you made in My Profile NOT appearing in the build-token picker (use "Create new token" inside Advanced settings; it includes D1 Edit as of 2026-08 despite the docs), the picker defaulting to ANOTHER project's build token, Root directory hiding in the Advanced settings accordion (labelled "Path"), the *Create an app* wizard REFUSING the name of a Worker that already exists (connect from that Worker's own Settings → Builds instead — the exact case when migrating off GitHub Actions), the first build after co

- Skill: `okayus/cloudflare-workers-builds-keyless-deploy` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add okayus/cloudflare-workers-builds-keyless-deploy`
- Raw SKILL.md: https://api.skillmd.com/api/skills/okayus/cloudflare-workers-builds-keyless-deploy/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: okayus (https://skillmd.com/u/okayus)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/okayus/cloudflare-workers-builds-keyless-deploy

---


# Cloudflare Workers Builds: Keyless Deploy

Replace "GitHub Actions runs `wrangler deploy` with a `CLOUDFLARE_API_TOKEN` secret" with **Workers Builds**: Cloudflare pulls the repo via its GitHub App and builds/deploys on Cloudflare's side. The deploy credential (a build token) lives **inside Cloudflare** and never exists in GitHub, the repo, or any dev sandbox. GitHub Actions keeps only test/lint — **Actions Secrets become empty**.

**Why not OIDC?** As of 2026-08, the Cloudflare API has **no OIDC / workload identity federation** (wrangler-action supports only `apiToken`; the OIDC feature request remains an open GitHub Discussion — workers-sdk#11434). Workers Builds is the only mechanism where GitHub holds zero Cloudflare credentials. Verified against live docs 2026-08-08; the dashboard ceremony below was re-run and corrected on kokemusu 2026-08-23 (see *Verified on kokemusu*).

## When to use this skill

- New or existing Workers project deployed from a GitHub repo, especially with an autonomous agent in the loop (no long-lived secret should be reachable from CI or a sandbox)
- Migrating off a `deploy.yml` + `CLOUDFLARE_API_TOKEN` setup
- NOT for: Pages projects (different product), pipelines that must deploy from non-GitHub/GitLab CI (fall back to a minimal-permission custom token — see `cloudflare-api-token-permissions`)

## Architecture and the one invariant

Workers Builds triggers on **push** and does **NOT wait for GitHub CI results**. So the gate moves to merge time:

> **Invariant: code on `main` is always CI-green** — enforced by a branch ruleset (PR required + required status check + no bypass actors). Workers Builds then only ever builds green code.

```
PR branch push → GitHub Actions ci (typecheck/test)   [GitHub side, no secrets]
merge to main (ruleset: PR + check "ci" required)      [human or auto-merge]
   └→ Workers Builds: install → build → D1 migrate → wrangler deploy   [Cloudflare side]
```

See [references/ruleset.md](references/ruleset.md) for the exact `gh api` ruleset payload (pins the required check to the GitHub Actions app via `integration_id: 15368`, `bypass_actors: []` so even the repo owner cannot push main) and [references/ci-yml.md](references/ci-yml.md) for the secrets-free CI workflow.

## One-time human ceremony (secret-zero)

Once per project, by a human or a browser agent under human supervision (it creates a credential). Screen-by-screen labels (Japanese UI, English equivalents), direct URLs and browser-agent tips are in [references/dashboard-walkthrough.md](references/dashboard-walkthrough.md) — read it before driving the dashboard.

**Pre-flight, from the terminal, before opening the dashboard** (each of these stopped the kokemusu run):

```bash
git fetch origin && git ls-tree --name-only origin/main apps/web/wrangler.jsonc   # must print the path: the production branch must ALREADY contain the root directory
gh pr list --state open                                                          # the skeleton PR must be merged, not open
grep -n '"name"\|database_id' apps/web/wrangler.jsonc; wrangler d1 list           # name = the Worker name you will type; database_id = a real UUID that exists
wrangler whoami                                                                   # the account the dashboard is logged into (the browser may run on another machine — only the account matters)
wrangler deployments list                                                         # DECIDES THE ROUTE: "This Worker does not exist" [10007] → steps A; deployments listed → steps B
```

**Two routes, and that last command decides which.** A Worker that does not exist yet is created by the *Create an app* wizard — **steps A**. A Worker that **already exists** — typically one you have been deploying with `wrangler deploy` from GitHub Actions, i.e. exactly the migration this skill is for — **cannot** go through that wizard: it validates the project name against existing Workers and refuses with *"A project with this name already exists. Choose a different name."* (この名前のプロジェクトはすでに存在します。別の名前を選択してください). Connect it from the Worker's own settings instead — **steps B**. Do **not** rename the Worker to get past the wizard: the name is its workers.dev hostname, and for a WebAuthn app that invalidates every registered passkey. (The wizard rejects at validation, before anything is created — no orphan token, no orphan Worker.)

**Steps A — a Worker that does not exist yet** (dashboard as of 2026-08-23):

1. **GitHub App scope**. Workers & Pages → Create → *Continue with GitHub*. If the *Cloudflare Workers and Pages* GitHub App is **already installed** on the GitHub account (from an earlier project), **no authorization screen appears** — the repository list shows straight away with whatever scope that installation has. To keep **Only select repositories**, manage it on GitHub (Settings → Applications → *Cloudflare Workers and Pages* → Repository access) **before** connecting; narrowing an existing *All repositories* installation touches every project built from that account, so list them first.
2. **Setup dialog**. Project name = exactly `name` from wrangler.jsonc; build + deploy commands from the table below; **untick "Builds for non-production branches"** (it is in the dialog, ticked by default — no need to wait for Settings). Then **open the "Advanced settings" accordion** at the bottom: it hides three things — **Root directory** (labelled *Path*, default `/`), the **API token** picker, and build variables. In the picker choose **Create new token** and name it `<worker> Workers Builds`. **Do not leave the default**: the picker pre-selects the build token of whatever project you connected last (kokemusu's default was `nyalog Workers Builds`), which silently couples the two projects' deploy credentials.
3. **Deploy**. Click *Deploy*: the Worker is created (two placeholder `Upload` deployments appear at once) and the **first build starts as a manual build** of the production branch HEAD — roughly 2 minutes (init ≈ 50 s, clone, install, build, migrate + deploy). A *Workers Paid* upsell modal appears — dismiss it. The build page's *Build settings* panel shows exactly what was saved (commands, root directory, token name) — confirm it there.
4. **Settings → Builds afterwards**: confirm token and branch control, add **build watch paths** excludes (`docs/*`, `*.md`: type, Enter, then *Save* on the toast). Nothing else.

**Steps B — a Worker that already exists** (matatabetai 2026-09-02). Workers & Pages → the Worker → **設定 / Settings** → right-hand nav **ビルド / Builds** → *Git リポジトリ* card → **接続 / Connect**. Same values as the table below, but the dialog differs from the wizard:

1. **No project name field** — the Worker is already named, which is the point of this route. Repository and **本番ブランチ / Production branch** (`main`) come **pre-filled**; the build command loads a beat later (`読み込み中...` → `pnpm run build`).
2. The branch tick-box is labelled **プレビュービルドを有効化 / Enable preview builds** (not *Builds for non-production branches*) and is **ticked** — untick it. Confirm by eye: the **非本番ブランチのデプロイ コマンド** field disappears when it is really off.
3. **詳細設定 / Advanced settings** holds the same *Path* and API-token picker, plus a **ビルド キャッシュ / Build cache** toggle (off by default — leave it).
4. In the picker, *Create new token* sits at the **top** of the list here (bottom in the wizard), and choosing it **pre-fills the token name** as `Workers Builds - <YYYY-MM-DD HH:MM>` — overwrite it with `<worker> Workers Builds`. The pre-selected default is still another project's token.
5. **Connect starts no build.** The page returns with *"Git リポジトリにコミットをプッシュして最初のビルドを開始できるようになりました"* (push a commit to start the first build). So on this route the first build **is** push-triggered and **does** post a `Workers Builds: <worker>` check-run — the "first build is manual, no check-run" caveat applies to steps A only. Make that first push the commit that **deletes `deploy.yml`**: the old workflow is gone from the ref being pushed, so Actions does not deploy and the two pipelines never race.
6. Then the same watch-path excludes as A4, on the page you are already on.

**Dropped step — do not pre-create a custom token in My Profile.** The 0.2.0 recipe said to create a Workers Scripts + D1 + Account Settings + User Details + Memberships custom token first and "select it from the list". On 2026-08-23 that token **did not appear in the picker** at all (searched by name → "No labels found"); the picker listed only the dash-generated `<project> Workers Builds` tokens of other projects plus *Create new token* — even though the docs say you may "select one that you already own". If you already made one, delete it in My Profile → API Tokens: it is an unused token with Edit rights.

## Settings that matter

| Setting | Value | Trap if wrong |
|---|---|---|
| Worker/project name (steps A only) | exactly `name` from wrangler.jsonc | name mismatch → deploy creates a second Worker. If the name is *taken*, you are on the wrong route — use steps B |
| Root directory (Advanced settings!, labelled *Path* in the setup dialog) | the package dir containing wrangler.jsonc, e.g. `apps/web` | commands run at repo root; build + deploy both fail. Also fails if the production branch doesn't contain that directory yet (unmerged skeleton PR) — a branch problem that looks like a settings problem |
| Build command | `pnpm install --frozen-lockfile && pnpm run build` | explicit install guards monorepo lockfile auto-detection (lockfile lives at repo root, root directory doesn't); pnpm finds the workspace root upward automatically (log shows `Scope: all N workspace projects`) |
| Deploy command | `pnpm exec wrangler d1 migrations apply <DB_NAME> --remote && pnpm exec wrangler deploy` | migrations must precede deploy; `pnpm exec` uses the repo-pinned wrangler |
| API token (Advanced settings!) | **Create new token** in the picker, named `<worker> Workers Builds` | The docs (checked 2026-08-23) still describe the generated token as *Account Settings read, Workers Scripts / KV / R2 edit, Workers Routes edit, User Details / Memberships read* — **no D1**. The dashboard notice on the same day listed a wider set **including D1 Storage (edit)** (plus Vectorize, Queues, Pipelines, Containers, Cloudchamber, Connectivity Directory, AI Search), and `d1 migrations apply --remote` succeeded on the first build. If a build fails at the migrate step with `Authentication error [code: 10000]`, open My Profile → API Tokens and add **D1 Edit** to that token in place (`cloudflare-api-token-permissions`) — the error names auth but not the missing permission. Never accept the picker's pre-selected default (another project's token). Old wrangler (<3.83.0) failed with no output at all (workers-sdk #5077, closed 2026-04) |
| Branch control | production branch = `main`; **non-production branch builds OFF** (tick-box is in the setup dialog and in Settings → Builds) | ⚠️ preview versions share the **production D1 binding** (`preview_database_id` applies only to `wrangler dev`, not uploaded versions) — PR previews would hit prod data, and a preview running migrations would migrate prod |
| Build watch paths (Settings → Builds) | default `*` = build everything; to skip docs-only deploys, **exclude** `docs/*` and `*.md` (keep include `*`) | excludes are evaluated first and match only docs/markdown, so a code change is **never** skipped and a mixed commit still builds; safe because the required `ci` check is **independent** of Workers Builds (still runs, still gates merge). Do NOT skip CI via `paths-ignore` — the required check goes pending and the PR sticks. Always builds on 0-change / 3000+ files / 20+ commits. **A skipped commit gets NO `Workers Builds:` check-run at all** — not a check-run with conclusion *skipped* — so it is indistinguishable from a missed trigger unless you look at the commit's paths (see failure modes). Excludes match **nested** paths: `docs/*` + `*.md` skipped a commit touching `CLAUDE.md`, `docs/log.md` and `docs/plans/host-setup.md` (matatabetai 2026-09-02). Dashboard-only (not in wrangler.jsonc); the exclude box's grey `node_modules/**, .git/` is placeholder text, not a value |

Plan limits (2026-06): Free = 3,000 build min/month, 1 concurrent build, 20 min timeout — plenty for a solo project.

## Verification

```bash
# Cloudflare reports build status as a GitHub check run on the commit:
gh api repos/<owner>/<repo>/commits/<sha>/check-runs \
  --jq '.check_runs[] | {name, status, conclusion}'
# → {"name":"Workers Builds: <worker>","conclusion":"success"} + your "ci" check
# If `gh api` is denied by the project's .claude/settings.json, a PUBLIC repo answers unauthenticated:
curl -s -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/<owner>/<repo>/commits/<sha>/check-runs

wrangler deployments list                # new deployment after merge
wrangler d1 migrations list <DB> --remote  # "No migrations to apply!" = applied
curl https://<worker>.<subdomain>.workers.dev/health
```

**After steps A the production HEAD carries only the `ci` check-run.** That first build was a *manual* build and posts no `Workers Builds: <worker>` check — expected, and **not** the missed-trigger case below. (Steps B starts no build at all, so its first build is already push-triggered and does post the check-run.) The push → build path is proven only by the next commit to `main` (under the ruleset: a PR). Until then the evidence is the dashboard *Builds* tab plus `wrangler deployments list`, where the Workers Builds deploy shows `Source: Unknown (deployment)` and the two `Upload` entries created at the same minute are the Worker-creation placeholders.

Note: workers.dev URLs are always `<worker-name>.<account-subdomain>.workers.dev` — a bare `<name>.workers.dev` does not exist. If the account subdomain matters (OAuth redirect URIs, WebAuthn RP_ID), decide/rename it **before** registering those, since renaming changes every Worker URL in the account.

Build status lives in the **`Workers Builds: <worker>` check-run** above — that plus `wrangler deployments list` is how you read pass/fail/queued from the CLI. The Workers **Builds REST API** (`/accounts/<id>/builds/*`, documented at `/workers/ci-cd/builds/api-reference/`) needs a **user** token carrying `Workers Builds Configuration: Edit` — so a `wrangler login` OAuth token gets `10000` *Authentication error* (a real route the token lacks scope for, vs `7003` for a bogus path), and `wrangler` has no `builds` subcommand. For queued/running/failed detail beyond the check-run, use the dashboard **Builds** tab — the **Version history** tab lists only *successful* deploys, never failures/skips.

## Failure modes seen in the wild

- **The wizard rejects the project name: "A project with this name already exists"** → the Worker already exists (you are migrating off `wrangler deploy`/Actions). Not a name you should change — leave the wizard and use **steps B** (the Worker's Settings → Builds → Git リポジトリ → Connect).
- **"My custom token is not in the API token picker"** → expected as of 2026-08-23 (see *Dropped step*). Use *Create new token*; do not loop back to My Profile.
- **Build fails immediately, weird path errors** → Root directory not set (it's *Path* inside Advanced settings) — or the production branch does not contain that directory yet (skeleton PR unmerged). Check `git ls-tree origin/main <root>/wrangler.jsonc` before touching settings.
- **Build green until migration step, `Authentication error [code: 10000]`** → the selected build token lacks D1 Edit (another project's older token, or a generated token from before D1 was included). Add D1 Edit to the token in place, or switch to *Create new token* in Settings → Builds, then Retry **once**.
- **First build green but no `Workers Builds:` check-run on GitHub** → the first build is manual; prove the trigger with a real push to `main` instead of re-running it.
- **Nothing builds on push** → (a) push to a non-production branch with branch builds off (intended); (b) watch-paths excluded everything (see *Settings*); or (c) a **transient missed trigger** — even with a healthy connection, a production-branch merge, and watch-paths=`*`, Cloudflare occasionally creates **no build at all** for a commit. Diagnose with the commit's check-runs: a built commit carries a **`Workers Builds: <worker>`** check-run (app `cloudflare-workers-and-pages`); if only `ci` is present the build was **never triggered** — distinct from a *failed* check-run (built then failed). **Rule out (b) before concluding (c)**: an excluded commit produces exactly the same signature (no check-run whatsoever), so first check what the commit touched — `git show --stat <sha>` — against the exclude list. Only a commit touching at least one non-excluded path and still carrying no check-run is a missed trigger. `gh api repos/<owner>/<repo>/commits/<sha>/check-runs --jq '.check_runs[].name'`. **Re-trigger by pushing a new commit to `main`** — retrying the latest build in the dashboard rebuilds *that* commit, not the missed one. (Don't read `commits/<sha>/status` `total_count:0` as "no signal" — Workers Builds & Actions both report via the Checks API, not legacy statuses.)
- **Deploy succeeded but old code serves** → check `wrangler deployments list`; the dashboard build log tells you which commit was built.

## Verified on matatabetai (2026-09-02) — the existing-Worker route

Third application. Worker `matatabetai` (account subdomain `shiraoka`), pnpm monorepo, root directory `packages/web`, **already deployed by GitHub Actions** — the first time this skill met a Worker that existed.

- Pre-flight was all green *by the 0.3.1 reading* (`deployments list` printed 10 deployments, which 0.3.1 called "would be taken over") — and the wizard still refused at the project-name field. That sentence was wrong; it now selects the route. Nothing was created by the refusal: the API-token picker afterwards still listed only the other projects' tokens.
- Steps B connected on the first try. The saved panel survived a reload: root directory `packages/web`, production branch `main`, preview builds off, watch-path excludes `docs/*` + `*.md`, token `matatabetai Workers Builds`.
- The generated token's permission notice (expanded before connecting) again included **D1 Storage (編集)** and **Workers R2 Storage (編集)**, plus KV, Vectorize, Queues, Pipelines, Containers, Cloudchamber, AI Search (edit), Connectivity Directory (read+bind), Workers routes on all zones, and user details + memberships (read). Both bindings this project needs were covered without touching My Profile.
- The picker default was again another project's token (`kokemusu Workers Builds`) — third project, third time.
- **Watch-path excludes verified.** The next merge after the deploy.yml one touched only `CLAUDE.md`, `docs/adr/002-*.md`, `docs/log.md`, `docs/plans/host-setup.md`, `docs/roadmap.md`, `docs/status.md`. Two minutes later the commit still carried **only `ci`** and `/health` still served the previous deploy — so the excludes matched every one of those nested paths, and a skip is *silent*: no check-run is created.
- **The push-triggered build is now verified end to end.** Merging the `deploy.yml`-deleting PR put both `ci` (success) and `Workers Builds: matatabetai` (success) on the merge commit, and `/health` answered 200. So on route B the very first build is push-triggered, posts the check-run, and the old Actions deploy does not race it — the workflow is absent from the ref being pushed.

## Verified on kokemusu (2026-08-23) — and what is still open

Second application of this skill (after mazuoboeru / nyalog), driven by a browser agent (Claude in Chrome on a Mac, CLI on Linux). Worker `kokemusu`, account subdomain `shiraoka`, pnpm monorepo with root directory `apps/web`.

- Build `#de3ceaea`: init 53 s / clone 4 s / install 18 s / build 11 s / deploy 36 s. Log: `Detected the following tools from environment: pnpm@9.15.0, nodejs@24.18.0`, `Scope: all 2 workspace projects`. Migrate + deploy succeeded **first try** on the dash-generated token; `/health` → 200, `d1 migrations list --remote` → `No migrations to apply!`.
- The 0.2.0 "custom token first" step cost a full token-creation round and produced an orphan token (`workers-builds-kokemusu`, 5 permissions, never selectable) — hence *Dropped step* above.
- The picker's default was another project's token (`nyalog Workers Builds`) with a yellow "missing permissions: email_routing_…" notice — that notice is noise unless the Worker uses Email Routing.
- Pre-flight mattered: `main` did not yet contain `apps/web` (three PRs open) — connecting at that point would have failed the first build at the path step for a non-settings reason.
- UNVERIFIED: why a hand-made user token is absent from the picker (filtered by creator? by full permission set? — the docs say existing tokens are selectable). Re-check on the next project before restoring the old step.
- UNVERIFIED *on kokemusu specifically* (only its manual first build has run) — but the push → build → `Workers Builds: <worker>` check-run path itself is confirmed on matatabetai 2026-09-02, so expect the same here.
- UNVERIFIED: the English labels of the setup dialog (run under the Japanese dashboard); *Path* / *Advanced settings* are translations, *Root directory* is confirmed by the docs' settings table.

