A Copier template for Generality Labs Cloudflare Worker projects, plus the shared reusable CI/deploy workflows they all call. It encodes one standard so the Worker repos don't drift:
- TypeScript,
strict, type-checked withtsc --noEmit - vitest running inside workerd via
@cloudflare/vitest-pool-workers, so tests exercise real bindings (D1, R2) against local simulations - Named wrangler environments always: the top level of
wrangler.tomlis local dev/tests only, and every deploy targets[env.production](or[env.staging]) explicitly — local development can never bind production resources by accident - CI-driven deploys:
wrangler deployruns from GitHub Actions with a scopedCLOUDFLARE_API_TOKEN, migrations applied first, health smoke test after - pre-commit stack: Biome (lint + format — the TS analogue of ruff), zizmor (Actions security), actionlint, mdformat, optionally typos
- Optional static assets (
use_assets):public/served ahead of the Worker, with anASSETSbinding and a runtime test - Shared
worker-ciandworker-deployreusable workflows, so every repo's CI and deploy pipeline is a thin caller - Keep a Changelog
CHANGELOG.md, SHA-pinned actions, Dependabot for actions and npm - A Claude Code
SessionStarthook that pre-warms the toolchain - Node 24+ toolchain (
.nvmrc,engines,engine-strict)
uvx copier copy gh:Generality-Labs/cloudflare-worker-template my-new-workerYou'll be asked for the name and description, whether to add a staging environment, which resources the Worker uses (D1, R2, KV, cron triggers), whether it serves static files, the Node version, and whether to add a Playwright e2e setup, run the typos spell-checker, and open the automatic template-update PRs.
After scaffolding, the copier message lists the resource-creation commands
(wrangler d1 create / wrangler r2 bucket create) whose ids/names go into
wrangler.toml, and CI needs two repository secrets:
CLOUDFLARE_API_TOKEN— an account-owned token with Workers Editor at the Workers product scope (deploys any existing Worker, including its KV/R2/D1 bindings), plus Zone > Workers Routes > Write on each zone the Worker has routes or Custom Domains in, plus D1 Edit only if CI applies migrations. Routes Write is only needed to add, change or remove a route or Custom Domain; once one exists, Editor alone can redeploy it. Editor cannot create a Worker, so the very first deploy of a new Worker is done by hand (see below).CLOUDFLARE_ACCOUNT_ID— the Cloudflare account id
wrangler deploy for a Worker that does not exist yet needs Workers Admin.
Rather than give CI that, deploy once from a logged-in shell
(npx wrangler login, then npm run deploy); every later push to main is a
redeploy that Editor can do. The same applies the first time a new
[env.staging] is added.
Deployed targets are named wrangler environments; the top level of
wrangler.toml is what wrangler dev and the test pool read, pointing at
-dev resources that are simulated locally. The top-level Worker name is
suffixed -dev too, so a bare wrangler deploy (without --env) can never
overwrite the deployed production Worker — it would create a separate
<name>-dev Worker instead. Two wrangler gotchas the scaffold
encodes, because everyone hits them once:
- Named environments do not inherit bindings.
[[d1_databases]],[[r2_buckets]]and[triggers]must be repeated per environment, pointing at that environment's own resources. The scaffold writes every block out explicitly. - Cron triggers fire in every environment that declares them. The scaffold declares crons in staging as well as production; delete the staging block for a job that must not run twice (email, paid APIs, ...).
With use_staging, every push to main deploys staging first and production
second, each as a GitHub environment. Add required reviewers to the
production GitHub environment in repo settings to turn that hand-off into a
manual approval gate — note this needs GitHub Team+ (or a public repo); on
the Free plan for private repos the environment exists but reviewers can't be
required. Each deploy job smoke-tests the Worker afterwards when a
HEALTH_URL variable is set on the GitHub environment.
One constraint worth knowing: the deploy pipeline passes the Cloudflare
secrets to the reusable workflow explicitly (zizmor flags secrets: inherit,
and with reason). That means the secrets live at the repository level. If
you need different tokens per environment, switch the scaffolded deploy.yml
to secrets: inherit and silence the finding — a deliberate, per-repo choice.
Secrets never live in wrangler.toml (that's for non-secret [vars]) or in
git. The scaffold's workflow:
- Declare each secret as a
KEY=line in the committed.dev.vars.example(suffix# optionalfor ones an environment may legitimately lack). cp .dev.vars.example .dev.varsand fill in dev values —wrangler devand vitest read it directly.- For deploys:
cp .dev.vars.example .dev.vars.production(and.dev.vars.staging), fill in that environment's values, thennpm run secrets/npm run secrets:staging. The script validates every required value before pushing anything, so a typo can't leave the Worker half-updated.
All the copies are gitignored; only .dev.vars.example is committed. Note
that wrangler secret put creates a new Worker version — long-running
Workflow instances keep the version (and secrets) they started with.
The org convention: every deployed Worker hostname sits behind a Cloudflare Access one-click app from day one — staging stays behind it permanently, so half-baked deploys can never leak; production stays behind it until launch. Turn it on per Worker in the dashboard (Workers & Pages -> the Worker -> Settings -> Domains & Routes -> workers.dev -> Enable Cloudflare Access).
Smoke tests through Access. The deploy smoke test requires a real 200, so
an Access-protected HEALTH_URL needs a service token: in Zero Trust ->
Access -> Service Auth, create a token; on each protected Access app add a
policy with decision Service Auth that includes that token; then set the
token's id/secret as the ACCESS_CLIENT_ID / ACCESS_CLIENT_SECRET
repository secrets (they resolve in the caller's context, like the
Cloudflare ones).
Making production public at launch — scripts/set-public-access.sh <on|off|status> attaches/detaches a named Bypass policy on the production
app without touching the app's own policies, so off restores exactly the
prior state; it verifies the live behaviour from outside afterwards, and the
on direction asks for typed confirmation (or --yes non-interactively).
Two hard-won API-token notes (they apply to the script, which is why it reads
CLOUDFLARE_API_TOKEN from the environment rather than reusing CI's):
editing Access needs an account-scoped token with BOTH "Access: Apps" and
"Access: Policies" write (Policies-only looks fine right up until POST apps
fails with [1010] auth.forbidden); and keep Access-editing rights out of
the CI deploy token — mint short-TTL tokens for the occasional toggle
instead. Also note Access does not log requests a Bypass policy admits.
The Worker runs on workerd, not Node, so the node_version copier question
only picks the toolchain (wrangler, vitest, tsc) — it never affects the
deployed Worker's runtime. The scaffold pins that choice three ways: the
rendered .nvmrc, package.json's engines.node, and .npmrc's
engine-strict=true. nvm use / fnm use pick up .nvmrc automatically; CI
uses the same value via each reusable workflow's node-version input, which
the scaffolded ci.yml / deploy.yml always pass explicitly.
Why 24+: Node 22 ships npm 10, whose arborist crashes
(Cannot read properties of null (reading 'edgesOut')) resolving vitest's
optional peer set on a lockfile-less install of this scaffold. Node 24 and
later ship npm 11, which installs cleanly. The copier node_version question
validates against anything below 24, so a new project can no longer be
scaffolded for Node 22 at all. engines/engine-strict are the second line
of defence, not a full substitute for the validator: npm only checks
engines after it has built the dependency tree, so once a lockfile exists
(npm ci, or any npm install re-run) a mismatched Node cleanly fails with
EBADENGINE — but a truly fresh, lockfile-less npm install on Node 22
still hits the raw arborist crash first, because that check never gets a
chance to run. Run nvm use (or otherwise switch to .nvmrc's version)
before the first npm install, not after it fails.
Generated projects call these rather than duplicating CI. To bump CI for every
repo at once, change it here and move the v1 tag (releases move it via
bump-v1.yml).
worker-ci.yml — install, tsc --noEmit,
vitest, and the pre-commit stack:
jobs:
ci:
uses: Generality-Labs/cloudflare-worker-template/.github/workflows/worker-ci.yml@v1
with:
node-version: "26"worker-deploy.yml — optional D1
migrations, wrangler deploy --env <env>, health smoke test; called once per
environment. The smoke test requires an actual 200 (an Access login
redirect does not count); for Access-protected Workers set the
ACCESS_CLIENT_ID / ACCESS_CLIENT_SECRET repository secrets to an Access
service token that a Service Auth policy on the app allows:
jobs:
production:
uses: Generality-Labs/cloudflare-worker-template/.github/workflows/worker-deploy.yml@v1
with:
environment: production
secrets:
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}npm is assumed throughout (both scripts and lockfile) — it's what the existing Worker repos use, and workers projects have no build step for a faster package manager to speed up.
Two complementary gates for the staging -> production hand-off:
- Human approval — required reviewers on the
productionGitHub environment. Needs GitHub Team+ or a public repo. - Automated e2e gate — the scaffolded
deploy.ymlcarries a commentede2e-gatejob that runs between the staging and production deploys, inside thestagingGitHub environment (so it can read staging secrets), and calls a repo-ownedscripts/e2e-check.sh. Write that script so it runs identically in CI and by hand: every knob an env var with a default, a poll-with-deadline rather than a fixed sleep, a failure message that names the exact tail/queue/log commands to triage with, and everything it creates tagged withRUN_TAG(CI passesrun_id-run_attempt) so a retry can't collide with — or be silently deduplicated against — a previous attempt.
Resources that wrangler.toml can't declare (queues, DLQs, R2 event
notifications, lifecycle rules, CORS) get created by the scaffolded
scripts/setup-resources.sh <env> — an idempotent, re-runnable skeleton with
the wrangler footguns already encoded (a duplicate queue reports "already
taken"; r2 bucket notification create silently double-delivers if repeated).
One more convention: scope one Cloudflare API token per project (name the
repo secret accordingly, e.g. CLOUDFLARE_API_TOKEN_<PROJECT>, and adjust
deploy.yml) rather than sharing one broad token across repos — a leaked or
over-scoped token then only reaches one project's resources.
A second Worker (a scanner, a queue consumer) lives as a second config file:
wrangler.<name>.toml, deployed withwrangler deploy --config wrangler.<name>.toml --env <env>.- npm scripts follow
<verb>:<worker>[:<env>], production unsuffixed:deploy:scanner,deploy:scanner:staging,tail:scanner. - Nothing is shared across config files — bindings,
[vars], and[observability]must be repeated in each one, per environment. - Secrets are per-Worker: push to every config
(
wrangler secret put --config wrangler.<name>.toml --env <env>), or the second Worker fails at runtime while the first looks healthy. - Deploy order matters within an environment: deploy the dependency
Worker first (one reusable-workflow call per Worker per environment, with
needs:chaining), so a contract change never leaves the main Worker calling an older peer.
The test pool runs the Worker in workerd with simulated local resources. With
use_d1, vitest.config.ts reads migrations/ and a setup file applies them
to the simulated database before every run — so tests exercise the actual
migrations, not a hand-maintained copy of the schema, and a migration that
breaks the schema fails CI before it reaches a real database.
Tests are split into two vitest projects: unit (plain Node, for
pure-function modules — keep those free of runtime cloudflare:* imports;
type-only imports are erased and fine) and worker (inside workerd, real
bindings via cloudflare:test). Only test/worker.test.ts pays the workerd
startup cost; everything else runs at plain-Node speed.
With use_playwright, npm run test:e2e additionally drives the Worker over
real HTTP: Playwright launches wrangler dev as its web server (probing
/health for readiness — inject test secrets with --var flags on that
command) and runs the specs in e2e/. Local-only by design; install browsers
once with npx playwright install chromium.
Answer no to use_typos and the hook is left out of the generated
.pre-commit-config.yaml. Worth doing for projects whose vocabulary the
checker doesn't know — domain terms, non-English proper nouns — or that commit
generated data. The hook is configured report-only (--write-changes is
deliberately dropped from its defaults): a spelling correction should need a
human to approve it.
From inside a project that was generated from this template (it has a
.copier-answers.yml):
uvx copier updateCopier does a 3-way merge between the old template output, the new output, and your local edits — so you get template improvements without losing your customizations.
Answer yes to use_template_update (the default) and the scaffold gets a
template-update.yml workflow that runs copier update weekly (and on
demand) and opens a PR when the template's scaffolded files have changed.
Reusable-workflow changes need no update run: consumers pin @v1, so moving
the tag propagates those immediately.
The workflow opens its PR with the default GITHUB_TOKEN, which needs
Settings → Actions → General → Allow GitHub Actions to create and approve
pull requests turned on. Without it the run pushes chore/template-update
and then fails with GitHub Actions is not permitted to create or approve pull requests.
copier update needs a .copier-answers.yml recording which template
revision the project was generated from; a repo that predates the template
has none. Establish that baseline by hand, once:
-
On a branch, render the template into a scratch directory with the answers the project should have, pinned to a release tag:
uvx copier copy --trust --vcs-ref v1.1.0 \ --data project_name=my-worker --data project_description="..." \ --data use_kv=true --data use_assets=true \ gh:Generality-Labs/cloudflare-worker-template /tmp/render -
Copy in everything that does not exist yet, then merge the rest by hand:
rsync -a --ignore-existing /tmp/render/ ./ git status --short # new files diff -rq --exclude=node_modules --exclude=.git /tmp/render . # files to merge by hand
The usual hand-merges are
.github/workflows/ci.yml,.gitignore,README.md,package.json,src/index.ts,tsconfig.json, andwrangler.toml. Keep the project's code and resource ids; take the template's structure (named environments with a-devtop level, thetypecheckscript,test/as the test directory —git mv tests testif the project used the plural, the template's convention). A few more things a real adoption needs that are easy to miss because nothing fails loudly without them:package.jsonneeds"type": "module"— the vitest pool is ESM-only.- If any test imports a
node:module,tsconfig.json'stypesarray needs@types/nodeand"node"added: a non-emptytypesarray disables TypeScript's automatic@typesdiscovery, so leaving it out fails silently until that import is type-checked. - Reconcile
vitest.config.tsandtest/worker.test.tsagainst the render so the Copier baseline actually matches whatcopier updatewill diff against later, rather than diverging from day one.
-
Copy
/tmp/render/.copier-answers.ymlinto the repo and set_src_pathtogh:Generality-Labs/cloudflare-worker-template(a local render records the local path)._commitmust be the tag you rendered. -
Prove the baseline holds: in a throwaway clone of the branch, run
uvx copier update --defaults --trust --vcs-ref v1.1.0. Expected: no changes (or only the files you hand-merged, as no-op re-applications). Conflict markers here mean a hand-merge diverged from the template in a way copier cannot follow; fix the file until the update is clean. -
Commit the baseline as one commit, separate from reformatting (Biome and mdformat will touch most files; do that in its own commit so
git blamestays useful).
Template CI keeps a fixture for this path: it renders the previous release,
customises it the way a real project does (renamed binding, extra routes and
files), then runs copier update to the commit under review and fails if a
customisation was lost or a conflict appeared.
Tagged releases move a v1 major tag via bump-v1.yml. Generated projects
pin the reusable workflows to @v1; a repo-local .github/zizmor.yml allows
tag-pinned refs from Generality-Labs/* while still requiring commit-SHA pins
for third-party actions, and the generated .github/dependabot.yml tells
Dependabot to leave Generality-Labs/* alone so it doesn't rewrite the moving
tag to a fixed version on every release.
A change that moves v1 runs in every consumer's CI without a PR there. New
checks must ship default-off, or default-on only when verified
credential-free and green against every live consumer, with an input to
disable them; anything a consumer must act on is a major (v2) and a new tag.
worker-ci.yml's and worker-deploy.yml's node-version input default moved
22 -> 26 under v1 (see Node version) rather than as a
major bump: the scaffold always passes node_version explicitly, so no
consumer relying on the default silently changed underneath it. A consumer
that omits node-version and wants something other than 26 should now pass
it explicitly.
Each pull request adds a changelog fragment under changelog.d/
(uvx --from scriv scriv create) instead of editing CHANGELOG.md. Releases
use python-project-template's reusable release workflows, pinned to its @v1,
in the mode that takes the version from the latest vX.Y.Z tag:
- Actions → Prepare template release → Run workflow. It collects
changelog.d/intoCHANGELOG.mdand opens a Release vX.Y.Z pull request.autopicks minor when a fragment adds, changes, deprecates or removes something, and patch otherwise. - On the pull request's Checks tab, click Approve workflows to run, then review it.
- Merge it. Template release on merge tags the merge commit, creates the
GitHub release, and starts
bump-v1.yml, which checks the changelog, annotates the tag and movesv1.
Both steps need Settings → Actions → General → Allow GitHub Actions to
create and approve pull requests. Re-running either workflow after a failure
is safe. If bump-v1 fails after the tag exists, re-run it, or start it with
gh workflow run bump-v1.yml -R Generality-Labs/cloudflare-worker-template --ref vX.Y.Z; on a dispatch it accepts only the newest final v1.X.Y tag.
Publishing a release by hand from the GitHub UI still starts bump-v1, which
refuses it unless the release's changelog section is already on main.
Paid for in incidents on real projects (mostly logfile-upload); read before debugging Cloudflare behaviour from scratch.
wrangler r2 object put/gettalks to the LOCAL simulated store by default (.wrangler/state/), not the real bucket — always pass--remote. A local put "verified" by a local get while the real bucket stays empty has burned two debugging sessions;wrangler r2 bucket info(always remote) showingobject_count: 0is the fast tell.- Multiple Cloudflare accounts?
export CLOUDFLARE_ACCOUNT_ID=...or wrangler may silently target the wrong (empty) one. - Never pipe
wrangler deploythroughtail/head— it masks the exit code and has manufactured a false "deploy succeeded". - R2 S3-API credentials: the Access Key ID is the API token's
id; the Secret is the SHA-256 of the token value, shown once. Wrong key id →Unauthorized; right id + wrong secret →SignatureDoesNotMatch. An account id pasted as an access key looks plausible (also 32 hex chars). - Queues: consumers should always declare
dead_letter_queue— exhausted retries otherwise DELETE the message. Create the DLQ before the consumer deploys. Wrangler cannot print message bodies; triage via the dashboard Queues view or the REST pull API. Log derived ids next to errors so triage doesn't start from a bare string. - Workflows: instance ids must start with an alphanumeric (a derived
leading
-is rejected withinstance.invalid_id), andcreateBatchsilently SKIPS duplicate ids within the retention window — the types doc comment claiming it throws is wrong. Running instances stay pinned to the Worker version (and secrets) they started on. - Containers: the default Workflow step-retry budget (~100s) is smaller
than a real container cold start — size retries to outlast it, use constant
backoff, and pin the arithmetic with a test. The entrypoint is PID 1:
trapTERM/INT and run long startup work backgrounded behindwait, or the runtime waits out a 15-minute grace while the zombie squats amax_instancesslot. Givemax_instancesheadroom — every deploy briefly doubles instances while old versions drain. Container stderr is NOT inwrangler tail; it's in the dashboard observability logs (type: cf-container). - Structured logs:
console.log("event_name", JSON.stringify({...}))with snake_case event names, one per phase — retrofitting this after an opaque incident is the expensive way to learn it. Logerr.stackserver-side and return a generic message, so internals never leak.
The template's structure (copier layout, reusable-workflow versioning, pre-commit stack, template-update machinery) is adapted from MattFisher/python-project-template, with the Python toolchain swapped for the Workers one.