Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

67 Commits

Folders and files

Repository files navigation

cloudflare-worker-template

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 with tsc --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.toml is 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 deploy runs from GitHub Actions with a scoped CLOUDFLARE_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 an ASSETS binding and a runtime test
  • Shared worker-ci and worker-deploy reusable 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 SessionStart hook that pre-warms the toolchain
  • Node 24+ toolchain (.nvmrc, engines, engine-strict)

Scaffold a new Worker

uvx copier copy gh:Generality-Labs/cloudflare-worker-template my-new-worker

You'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

First deploy

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.

How environments work

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:

  1. 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.
  2. 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

Secrets never live in wrangler.toml (that's for non-secret [vars]) or in git. The scaffold's workflow:

  1. Declare each secret as a KEY= line in the committed .dev.vars.example (suffix # optional for ones an environment may legitimately lack).
  2. cp .dev.vars.example .dev.vars and fill in dev values — wrangler dev and vitest read it directly.
  3. For deploys: cp .dev.vars.example .dev.vars.production (and .dev.vars.staging), fill in that environment's values, then npm 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.

Cloudflare Access in front of deployed Workers

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.

Node version

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.

The reusable workflows

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.

Gating production on staging

Two complementary gates for the staging -> production hand-off:

  • Human approval — required reviewers on the production GitHub environment. Needs GitHub Team+ or a public repo.
  • Automated e2e gate — the scaffolded deploy.yml carries a commented e2e-gate job that runs between the staging and production deploys, inside the staging GitHub environment (so it can read staging secrets), and calls a repo-owned scripts/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 with RUN_TAG (CI passes run_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.

Two Workers in one repo

A second Worker (a scanner, a queue consumer) lives as a second config file:

  • wrangler.<name>.toml, deployed with wrangler 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.

Testing against real bindings

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.

Turning off typos

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.

Update an existing project when the template changes

From inside a project that was generated from this template (it has a .copier-answers.yml):

uvx copier update

Copier 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.

Adopt the template in an existing Worker repo

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:

  1. 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
  2. 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, and wrangler.toml. Keep the project's code and resource ids; take the template's structure (named environments with a -dev top level, the typecheck script, test/ as the test directory — git mv tests test if 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.json needs "type": "module" — the vitest pool is ESM-only.
    • If any test imports a node: module, tsconfig.json's types array needs @types/node and "node" added: a non-empty types array disables TypeScript's automatic @types discovery, so leaving it out fails silently until that import is type-checked.
    • Reconcile vitest.config.ts and test/worker.test.ts against the render so the Copier baseline actually matches what copier update will diff against later, rather than diverging from day one.
  3. Copy /tmp/render/.copier-answers.yml into the repo and set _src_path to gh:Generality-Labs/cloudflare-worker-template (a local render records the local path). _commit must be the tag you rendered.

  4. 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.

  5. Commit the baseline as one commit, separate from reformatting (Biome and mdformat will touch most files; do that in its own commit so git blame stays 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.

Versioning

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.

Releasing this template

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:

  1. Actions → Prepare template release → Run workflow. It collects changelog.d/ into CHANGELOG.md and opens a Release vX.Y.Z pull request. auto picks minor when a fragment adds, changes, deprecates or removes something, and patch otherwise.
  2. On the pull request's Checks tab, click Approve workflows to run, then review it.
  3. 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 moves v1.

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.

Operational gotchas

Paid for in incidents on real projects (mostly logfile-upload); read before debugging Cloudflare behaviour from scratch.

  • wrangler r2 object put/get talks 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) showing object_count: 0 is the fast tell.
  • Multiple Cloudflare accounts? export CLOUDFLARE_ACCOUNT_ID=... or wrangler may silently target the wrong (empty) one.
  • Never pipe wrangler deploy through tail/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 with instance.invalid_id), and createBatch silently 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: trap TERM/INT and run long startup work backgrounded behind wait, or the runtime waits out a 15-minute grace while the zombie squats a max_instances slot. Give max_instances headroom — every deploy briefly doubles instances while old versions drain. Container stderr is NOT in wrangler 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. Log err.stack server-side and return a generic message, so internals never leak.

Prior art

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages