Cloudflare Workers Builds: publish a static site on a GitHub Release, with no token in GitHub
In short. Connect the Worker to the repository with Workers Builds and make
productionthe only branch it builds. Turn preview builds off. Have the build write its commit into/version.txt. A workflow that runs only when a release is published checks the commit is onmain, force-pushes it toproduction, and polls/version.txtuntil the live site serves it. GitHub holds no Cloudflare credential, a merge never publishes, and rolling back is a release on an older commit.
A deploy token in GitHub broke, so GitHub no longer holds one
The first version of this site's pipeline deployed from GitHub Actions with a narrow Cloudflare token stored as an environment secret. It started failing with a 401 the day old tokens were tidied away in the dashboard, and two pasted replacements failed the same way. Rather than work out which token the secret had really held, the deploy moved to Cloudflare's side. Cloudflare builds and deploys; GitHub only decides when. The design post covers why. This is the checklist.
flowchart TB
R["Author publishes a release"] --> C{"Is the commit<br/>on main?"}
C -- "no" --> X["Run fails,<br/>nothing moves"]
C -- "yes" --> P["Workflow force-pushes it<br/>to the production branch"]
P -- "push event,<br/>Cloudflare's GitHub app" --> B["Workers Builds:<br/>bun run build,<br/>writes /version.txt"]
B -- "wrangler deploy,<br/>tagged with the commit" --> L["Live site"]
L -- "workflow polls /version.txt<br/>every 20 s, up to 20 min" --> G["Run goes green:<br/>the release is live"]What you need
- A site that builds to a folder of static files. Here that is Next.js 16 with
output: "export"intoout/, a Pagefind search index built after it, and Bun 1.4 as the package manager. - A Worker that serves the folder as static assets, with
wranglerpinned inpackage.json. - The domain's zone in the same Cloudflare account.
- A GitHub repository you can install Cloudflare's GitHub app on. The environment tag rule below works on a private repository on GitHub's free plan; branch protection and rulesets do not.
- Names used below: Worker
my-blog, domainblog.example.com, release tagsvYYYY.MM.DD.
Steps
1. Make the build say which commit it is
Add a small script that writes the commit into the output, and run it last in the build:
"build": "next build && pagefind --site out && node scripts/write_version.mjs"
// scripts/write_version.mjs: runs on Node and Bun
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";
function commit() {
const fromEnv = process.env.WORKERS_CI_COMMIT_SHA || process.env.GITHUB_SHA;
if (fromEnv) return fromEnv.trim();
try {
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
} catch {
return "unknown";
}
}
writeFileSync("out/version.txt", `${commit()}\n`);
Workers Builds sets WORKERS_CI_COMMIT_SHA to the commit it is building. The file is public and harmless, and it is the only thing the release workflow needs to read to know the release is live. No credential involved.
2. Describe the Worker in wrangler.jsonc
{
"name": "my-blog",
"compatibility_date": "2026-10-01",
"assets": {
"directory": "./out",
"not_found_handling": "404-page"
},
"routes": [{ "pattern": "blog.example.com", "custom_domain": true }]
}
The name must match the Worker you connect in step 4. There is no Worker script: the static assets are the whole site, and 404-page serves the 404.html that Next.js generates.
3. Create the production branch once
git push origin main:refs/heads/production
From now on only the release workflow pushes to it.
4. Connect Workers Builds
In the Cloudflare dashboard: Workers & Pages, the Worker, Settings, Builds, Connect.
- Git repository. Install Cloudflare's GitHub app with "Only select repositories" and pick this one. It never needs your other repositories.
- Branch control. Production branch
production. - Build command.
bun run build - Deploy command.
npx wrangler deploy --tag "$WORKERS_CI_COMMIT_SHA". The tag puts the commit on the Worker version, so the dashboard's version list says what each deploy was. - Root directory
/, and leave the build watch paths at*. - API token. Let Cloudflare create its own build token. It shows in the settings as "Workers Builds" with a date. Do not paste one of yours: the point is that no person handles it.
5. Pin the runtime version
On the same page, under Variables and secrets for production, add a variable BUN_VERSION with the version you develop with, here 1.4.2. The build image ships an older Bun, and without this the first build failed at install:
Detected the following tools from environment: bun@1.2.15, nodejs@24.18.0
error: Unknown lockfile version
error: lockfile had changes, but lockfile is frozen
Set it, then Retry build on the failed build. When you upgrade Bun in the repository, change this variable in the same breath.
6. Turn preview builds off
Still under Settings, Builds, switch to the Previews tab and turn off Builds for Preview branches. It is on by default and builds every other branch. Its variables are separate from production's, so it ran the old Bun and put a red "Workers Builds" check on every pull request. Even when such builds pass, they upload versions nobody released. Changing Branch control on the Production tab does not touch this switch.
On the Domains tab, leave the workers.dev and preview addresses off, so the custom domain is the only way in.
7. Restrict a GitHub environment to release tags
In the repository's Settings, Environments, create production. Under Deployment branches and tags, choose selected branches and tags and add a tag rule v*. Put no secrets in it. The environment exists for the rule: only a job running from a release tag can use it.
8. Add the release workflow
# .github/workflows/deploy.yml
name: deploy
on:
release:
types: [published]
permissions:
contents: write # to move the production branch; nothing else
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
name: publish ${{ github.event.release.tag_name }}
if: github.event.release.prerelease == false
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # to check that the released commit is on main
- name: Refuse a commit that is not on main
run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Move the production branch to the released commit
run: git push --force origin "$GITHUB_SHA:refs/heads/production"
- name: Wait until the live site serves the released commit
run: python3 scripts/wait_for_live.py "$GITHUB_SHA"
- Pre-releases are skipped, which makes them a way to tag without publishing.
- The push is forced because a rollback moves
productionbackwards. GITHUB_TOKENpushes do not start other GitHub workflows, but Cloudflare's app still receives the push event, which is all that matters here.- The concurrency group queues a second release behind the first instead of racing it.
9. Add the wait script
#!/usr/bin/env python3
# scripts/wait_for_live.py <commit-sha>: exit 0 once the live site serves the commit
import sys, time, urllib.error, urllib.request
SITE = "https://blog.example.com"
AGENT = "my-blog release check (+https://blog.example.com)"
def live_version() -> str:
url = f"{SITE}/version.txt?t={int(time.time())}" # a fresh URL each time, past any cache
req = urllib.request.Request(url, headers={"User-Agent": AGENT, "Cache-Control": "no-cache"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode("utf-8", "replace").strip()
except urllib.error.URLError as e:
return f"(no answer: {getattr(e, 'reason', e)})"
sha, deadline = sys.argv[1].strip().lower(), time.time() + 1200
while time.time() < deadline:
seen = live_version()
if len(sha) == 40 and seen.lower() == sha:
print(f"live: {SITE} serves {sha}")
sys.exit(0)
print(f"waiting: live is {seen[:40]}, want {sha}", flush=True)
time.sleep(20)
sys.exit("timed out: check the Workers Builds log in Cloudflare")
Name your check in the user agent; some zones challenge Python's default one. The same function makes a read-only status check: compare live_version() with git rev-parse origin/main and print in sync or behind.
10. Delete what you no longer need
- The
CLOUDFLARE_API_TOKENsecret, from the repository and from the environment. - The old deploy token in Cloudflare, and its entry in your password manager.
- Any manual deploy script, and the make target that ran it. Keep the read-only status check.
Then add a test so nobody quietly brings one back:
from pathlib import Path
def test_no_workflow_holds_a_cloudflare_credential_or_moves_production():
for path in Path(".github/workflows").glob("*.yml"):
text = path.read_text()
assert "CLOUDFLARE_API_TOKEN" not in text, path.name
assert "wrangler deploy" not in text, path.name
if path.name != "deploy.yml":
assert "refs/heads/production" not in text, path.name
11. Publish
Create a release on main from the web, the mobile app or the command line:
gh release create v2026.10.08 --target main --generate-notes
A second release on the same day is v2026.10.08.1.
Verify it worked
-
The release run goes green, ending with
live: https://blog.example.com servesand the commit. -
The live file matches:
curl -s https://blog.example.com/version.txt -
The Worker's Deployments tab shows a build of
productionat that commit, and the new version carries the commit as its tag. -
A pull request shows only your own CI checks, with no "Workers Builds" check.
Roll back
For a considered rollback, create a release whose tag points at the last good commit, for example v2026.10.08.2 on an older commit of main. The workflow accepts it because the commit is on main, force-pushes production backwards, and Cloudflare builds it like any other release. I have not needed this one yet; nothing in the chain cares which direction the branch moves, but try it once on a quiet day before you need it. In a hurry, the Worker's Deployments tab can roll back to a previous version straight away; the next release then puts the branch and the site back in step.
Gotchas
Unknown lockfile versionat install. The build image's Bun is older than yours. SetBUN_VERSION(step 5) and retry the build.- A red "Workers Builds" check on every pull request. Preview builds are on. Turn them off on the Previews tab (step 6), then push a new commit: the old failed check stays on the old one.
- The release run times out but Cloudflare shows a successful build. Fetch
/version.txtyourself. If it saysunknownor a different commit, the build did not run under Workers Builds or the deploy went to a different Worker name. - Anyone with write access can push
production. On a one-author private repository that is the same boundary asmain. With a paid plan or a public repository, add a ruleset so only the workflow can update it.
FAQ
Is Cloudflare's build token not just a token somewhere else? It is a token, but Cloudflare creates it, scopes it, stores it and uses it. No person copies it, and it never appears in a GitHub secret, a log or a screenshot. The failure that started this, a person handling the wrong token, cannot happen.
Why not let Cloudflare build main on every merge?
Then every merge is a publish. The production branch is the release gate: Cloudflare builds whatever lands there, and only a published release puts anything there.
Does this work with Node instead of Bun?
Yes. Use npm run build as the build command, and set NODE_VERSION instead of BUN_VERSION if the image's default does not suit.
What does it cost? Nothing extra on the free plan for a blog: one build per release, well inside the free build minutes.
Related
- An el-cheapo blog on Cloudflare: the design this checklist implements.
- Infrastructure engineering on a (very low) budget: start here