deploy
Deploy a crouton app to Cloudflare Workers STAGING (auto-provisioning) — the DEFAULT deploy, staging only, never production. Handles the staging bootstrap (auto-creates D1+KV, syncs ids, migrates), wi
SKILL.md.claude/skills/deploy/SKILL.md
GitHubDeploy Skill — Cloudflare Workers
Deploys a crouton app to Cloudflare Workers (static assets) — the crouton deploy standard (#108). Wrangler auto-provisions the app's D1 + KV on the first deploy, so there's no manual resource/project creation, no id-juggling.
🟦 STAGING ONLY — this skill never deploys to production. "Deploy" defaults to staging here. Shipping to production is a deliberate, human-initiated action handled by the separate
/deploy-productionskill. Never deploy production as part of routine work.
Not Cloudflare Pages. We do NOT use
wrangler pages …,pages_build_output_dir, or the Pages "strip env" step anymore. If you find those, the app is on the old Pages path — see Migrating a Pages app → Workers below.
Environment & domain convention (#133)
Two environments, two domains — kept on separate registrable domains so a staging session can never authenticate against production (cookie isolation):
| Env | wrangler env | Worker | Domain |
|---|---|---|---|
| production | top-level | <app> |
<app>.friendlyinter.net |
| staging | env.staging |
<app>-staging |
<app>.pmcp.dev (public) |
The deploy-env is named staging (not preview): scripts are cf:staging /
db:migrate:staging, deploys use --env staging. (The general crouton CLI stays
domain-agnostic via --domain <zone>; the friendlyinter.net/pmcp.dev split is this
monorepo's convention, applied per app at its production cutover — #136 for triage.)
Usage
/deploy # Deploy current app to STAGING (auto-detected from cwd)
/deploy velo # Deploy a specific app to STAGING
# production → use the separate /deploy-production skill
Rules
- STAGING ONLY. This skill deploys to staging, never production (that's the separate
/deploy-productionskill). Always confirm the target app first. - Workers, not Pages —
NITRO_PRESET=cloudflare_module, output in.output/, deploy withwrangler deploy(neverwrangler pages deploy). - NEVER manually create D1/KV — they auto-provision from the id-less
wrangler.jsoncon first deploy. After provisioning, runsync:idsand commit the written-back ids (remoted1 migrations applyneeds them — workers-sdk#13632). - NEVER skip
nuxt preparebefore build in CI — rolldown tsconfig bug. (Locally, thecf:*scripts assumenode_modules/.nuxtare prepared frompnpm install.) hub: { db: 'sqlite' }— neverhub: { database: true }.postinstallmust be guarded —nuxt prepare 2>/dev/null || true, never bare (a bare prepare aborts the whole-monorepo install and fails every app's deploy).
How the pipeline works (one source of truth)
The deploy logic lives in the app's package.json scripts — the same commands
you run locally and that CI runs. Don't reinvent them step-by-step:
cf:deploy(production — run only via the/deploy-productionskill):build → wrangler deploy (auto-provision) → sync:ids → d1 migrations apply --remotecf:staging(isolated staging env):build → inject-wrangler-env → wrangler deploy --env staging → sync:ids → inject-wrangler-env → d1 migrations apply --env staging --remotesync:ids— queries wrangler, writes provisioned ids back intowrangler.jsoncdb:migrate/db:migrate:prod/db:migrate:staging— D1 migrations (local / remote / staging-remote)
A freshly scaffolded app (crouton init) already ships all of this:
wrangler.jsonc (id-less), scripts/sync-wrangler-ids.mjs,
scripts/inject-wrangler-env.mjs, drizzle.config.ts, the chained scripts, the
CF stubs + nitro aliases, and the guarded postinstall.
Workflow
Step 1: Detect app
- arg →
apps/{arg}/; else if cwd is inside an app → that app; else ask. - Verify it has
wrangler.jsonc+package.json.
Step 2: Pre-flight (run in parallel)
Confirm the app is Workers-ready:
wrangler.jsoncpresent, Workers-style (hascompatibility_flags: ["nodejs_compat"],d1_databases/kv_namespaces; nopages_build_output_dir).- Scripts
scripts/sync-wrangler-ids.mjs+scripts/inject-wrangler-env.mjsexist. drizzle.config.tsexists (sodb:generateworks).- Package scripts —
cf:deployis the Workers chain;postinstallis guarded. - CF stubs —
server/utils/_cf-stubs/exists;nuxt.config.tshasnitro.aliasfor passkey/webauthn/papaparse stubs and pins no preset. - Auth —
CLOUDFLARE_ACCOUNT_ID+CLOUDFLARE_API_TOKENavailable in the environment (see Credentials).
If anything is missing and the app is on the old Pages setup → Migrating a Pages app → Workers. If it's just missing files, copy them from apps/velo (the reference) or re-run the scaffolder.
Step 3: First (bootstrap) staging deploy
The id-less bindings auto-provision here. Confirm with the user, then deploy the isolated staging environment (its own auto-provisioned D1+KV):
cd apps/{app}
pnpm cf:staging # provisions + deploys the *-staging worker, migrates --env staging
Then commit the written-back ids (bootstrap → committed):
git add apps/{app}/wrangler.jsonc && git commit -m "chore({app}): commit provisioned staging D1/KV ids"
The production bootstrap (
cf:deploy, prod D1+KV,<app>.friendlyinter.net) is a deliberate, separate step — see the/deploy-productionskill. This skill stops at staging.
If you're an agent without Cloudflare egress (sandbox), you can't run these — verify what's verifiable (config,
pnpm sync:ids --dry-runlogic) and have the user run the CF-gated steps, pasting output (the #109/#113/#114 loop).
Step 4: Wire CI (opt in via deploy.config.json)
There is one generic workflow for all apps — .github/workflows/deploy-apps.yml
(#481/#638; the old per-app deploy-<app>.yml callers are retired — don't create one).
An app opts in by adding a deploy.config.json next to its package.json. Model on
apps/velo/deploy.config.json. Set: stagingUrl, productionUrl, layerPackages,
and watchPaths (the app + its extended crouton* packages + lockfile). The workflow's
detect job matches changed files against watchPaths and fans out one reusable
deploy-app.yml call per affected app. Merge to main/open a PR → isolated staging
with the URL commented on the PR; manual dispatch (app + environment inputs) →
production (#347). The fan-out uses secrets: inherit.
Ensure repo-level secrets CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN exist
(Settings → Secrets and variables → Actions).
Step 4.5: App (Worker) secrets
The app's own secrets (BETTER_AUTH_SECRET/BETTER_AUTH_URL, NUXT_*, etc.) live
on the Worker, NOT in wrangler.jsonc. Worker secrets persist across deploys,
so this is a one-time bootstrap per worker (prod + staging), not a per-deploy step.
Two ways:
- Manual (one-time):
npx wrangler secret bulk secrets.json(prod) /… --env staging(staging).BETTER_AUTH_URL/BASE_URLmust be the production domain (not localhost). Pages secrets do NOT carry over — re-provide the values. - Automatic (CI): store the whole bundle as a repository-level Actions secret
WORKER_SECRETS_JSON(a JSON object of{ "NAME": "value", … }). It MUST be repo-level, NOT an Environment secret — the deploy job is reached viasecrets: inheritfrom caller jobs that declare noenvironment:, so an Environment-scoped secret resolves EMPTY with no error and the Worker deploys without secrets (#1094). The reusabledeploy-app.ymlrunswrangler secret bulkfrom it on every deploy (--env stagingfor non-prod). Omit it to manage secrets manually. If the app depends on the bundle, set"secrets": { "required": true }in itsdeploy.config.json— an empty resolution then FAILS the deploy instead of silently skipping. Automation can't invent values — they must live in that secret once.
Step 5: Routine deploys (staging)
- CI (preferred): merge to
main(or open a PR) → the caller runs the staging pipeline (#347). - Local:
pnpm cf:stagingfrom the app dir. - Production is never routine — ship it deliberately via the
/deploy-productionskill.
Auto-seeded review login on staging previews (#608)
Every staging deploy auto-seeds a throwaway, loginable test account on the
preview's isolated D1 so a reviewer can open the URL and be inside the app in one
step — no register → create-team wall. deploy-app.yml runs
scripts/seed-review-login.mjs against the deployed Worker (the app's own
/api/auth/sign-up/email + a team via organization/create when the app doesn't
auto-make one), then prints a 🔑 Test login block in the PR's staging comment.
Creds are deterministic per preview (so redeploys reprint the same working login,
no user pile-up) and the step is best-effort (never fails the deploy). Optional repo
secret REVIEW_SEED_SECRET salts the password; production seeds nothing.
Migrating a Pages app → Workers
For an app still on the Pages setup (wrangler.toml, pages_build_output_dir,
wrangler pages deploy):
wrangler.toml→wrangler.jsoncin the Workers shape (seeapps/velo): droppages_build_output_dir; keepname/compatibility_*;d1_databases(reuse the existing proddatabase_id),kv_namespaces; add anenv.stagingblock with a separate{app}-staging-db+ KV (id-less to auto-provision, or existing staging ids).- Add
scripts/sync-wrangler-ids.mjs,scripts/inject-wrangler-env.mjs,drizzle.config.ts(copy fromapps/velo). - package.json — replace the Pages
cf:*scripts with the Workers chain (NITRO_PRESET=cloudflare_module,sync:ids,db:migrate:staging); keep the guardedpostinstall. - nuxt.config.ts — remove
nitro.preset: 'cloudflare-pages'(keep thenitro.aliasstubs). - CI — replace
deploy-{app}.yml(+ any-preview.yml) with the thin caller from Step 4; delete the Pages strip-env step (not needed on Workers). - Deploy + commit ids as in Step 3.
Credentials
The job/shell needs CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN.
CLOUDFLARE_ACCOUNT_ID— dashboard → Workers & Pages → Account ID (also the hex in the dashboard URL). Not secret.CLOUDFLARE_API_TOKEN— My Profile → API Tokens → Create Custom Token. For Workers + auto-provisioning the token needs (Account-scoped):- Workers Scripts: Edit
- D1: Edit
- Workers KV Storage: Edit
- (Workers R2 Storage: Edit if the app uses blob)
Cloudflare shows a token's value only once, and GitHub never reveals a saved secret — so mint a fresh dedicated token rather than reusing one.
Note: this differs from the old Pages token (which used Cloudflare Pages: Edit). A Pages-only token will fail to auto-provision D1/KV.
Troubleshooting
Couldn't find a D1 DB … missing database_id (on migrate)
The first deploy provisioned the DB but the id isn't in wrangler.jsonc yet. Run
pnpm sync:ids (after a deploy) and commit the result. cf:deploy/cf:staging do
this automatically.
Configuration file does not support "env" / redirected config rejects env
Wrangler 4.64+ rejects env in a redirected config. scripts/inject-wrangler-env.mjs
(run by cf:staging) re-injects env into .output/server/wrangler.json and removes
the redirect so --env staging deploys read it directly. No manual strip step.
papaparse RollupError / passkey/tsyringe errors
Add the CF stubs + nitro.alias (see scaffolder output / apps/velo).
KV namespace not found by sync:ids
It matches the auto-provisioned title <worker-name>-<binding> (e.g.
{app}-KV, {app}-staging-KV). The script logs the available titles if no match —
adjust only if your account names them differently.
Build OOM
Set NODE_OPTIONS='--max-old-space-size=8192' (CI sets this).
Deploy Learnings Location
Per-app deploy gotchas: docs/projects/{app}/{app}-deploy.md. Append new fixes there.
Reference implementation for everything above: apps/velo.