Skip to content

Bonbon Dino — Database & Deploy Checklist

Patterns for the database choice and Cloudflare deployment for Bonbon Dino full-stack client sites. Cloudflare is the deploy target for both dev and production; if you ever need a separate demo platform, see website-checklist.md §17, but the default is CF-only.

Reference impls: - Turso + Drizzle: rushroad_storage/lib/schema.ts, lib/db.ts, wrangler.toml, open-next.config.ts, .github/workflows/deploy.yml - D1 + raw prepare(): keilen_family_services/lib/db.ts, keilen_family_services/wrangler.jsonc, keilen_family_services/migrations/


1. Database Choice: Turso + Drizzle (default for multi-table) OR D1 + raw fetch (default for single-table)

Two valid defaults, pick one per project and stay consistent.

1a. Turso (LibSQL) + Drizzle ORM (default for projects with >1 table or cross-table joins)

What it does: A managed SQLite-compatible database (Turso, built on LibSQL) accessed through Drizzle ORM.

Why over Postgres/Neon/Prisma: - LibSQL runs in both Node.js (local dev, possible future Vercel demos) and Cloudflare Workers identically — no driver swap, no pg vs pg-edge. - SQLite schema fits a 1–10 table client site easily; we don't need joins-across-shards. - Drizzle generates clean TypeScript types from the schema with no codegen step. Prisma's prisma generate is painful to integrate with the OpenNext build. - Turso's free tier is generous; per-site DBs are cheap.

Local dev: Point TURSO_DATABASE_URL=file:./local.db in .env.local and Drizzle uses a local SQLite file. No need to spin up the cloud DB for every dev session.

Schema location: lib/schema.ts. Client: lib/db.ts.

Commands:

npx drizzle-kit push          # apply schema to current DATABASE_URL
npx drizzle-kit generate      # emit migration SQL files (for production)

1b. Cloudflare D1 + raw db.prepare() (default for single-table client sites)

What it does: Cloudflare's serverless SQLite-compatible database, accessed through the D1Database binding in the Worker. No ORM — just db.prepare('...').bind(...).first() / .run() / .all(). Provisioned by wrangler d1 create and bound in wrangler.jsonc under d1_databases.

Why pick D1 over Turso for a small site: - The binding is native to the Worker runtime — no network round-trip from the Worker to a Turso region. Latency is in the same VPC. - For a single-table client site (one admin row, a few settings rows) the ORM's type-generation overhead earns nothing. A 20-line lib/db.ts with a couple of prepare() helpers is enough. - D1 ships free with the Worker; no separate Turso account to provision and rotate auth tokens for. - Migrations are plain .sql files in migrations/, applied via wrangler d1 execute <db-name> --remote --file migrations/NNNN_*.sql. No codegen step.

Why pick Turso over D1: - Cross-table joins, complex schemas, or a need to use the DB from outside the Worker (e.g., a separate analytics script, a Vercel demo). Turso's libSQL speaks plain SQLite to every client. - Local dev without wrangler dev — Turso points at a file:./local.db. D1 local dev requires opennextjs-cloudflare dev so the binding is stubbed.

Pattern:

// lib/db.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'

export async function getDb(): Promise<D1Database> {
  const { env } = await getCloudflareContext({ async: true })
  if (!env.DB) throw new Error('D1 binding "DB" is not configured')
  return env.DB
}

wrangler.jsonc binding:

"d1_databases": [
  {
    "binding": "DB",
    "database_name": "<client-slug>-admin",
    "database_id": "<uuid-from-wrangler-d1-create>"
  }
]

Migrations:

# create the DB
wrangler d1 create <client-slug>-admin

# write migration files to migrations/
# 0001_admin_credentials.sql
# 0002_password_reset_tokens.sql

# apply locally (against wrangler dev's stub DB)
wrangler d1 execute <db-name> --local --file migrations/0001_*.sql

# apply to prod
wrangler d1 execute <db-name> --remote --file migrations/0001_*.sql

Critical gotcha — guard the binding at the boundary. getDb() returning env.DB with no guard means a missing/renamed binding surfaces as a vague TypeError: Cannot read properties of undefined (reading 'prepare') deep in a route. The error message points at a random route, not at the misconfiguration. Add if (!env.DB) throw new Error('D1 binding "DB" is not configured') and the failure shows up at the entry point with a clear, actionable message.

Reference impl: keilen_family_services/lib/db.ts, keilen_family_services/wrangler.jsonc, keilen_family_services/migrations/


2. ISO 8601 Text Dates (Not Int Timestamps)

What it does: All date columns are text storing ISO 8601 strings (2026-06-06T12:34:56.789Z). Not integer storing Unix epoch.

Why: Drizzle's integer date mode serializes round-trips fine on Node.js but caused "Invalid Date" crashes on Cloudflare Workers — the int was being lost across the Worker fetch boundary, deserializing as NaN. ISO text strings are inert: new Date(str) is the same on every runtime.

Schema pattern:

createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),

Read pattern: new Date(row.createdAt) on the consumer side — never assume row.createdAt is already a Date object.

If you want to start with int timestamps anyway, fine — but write the conversion at every read site and watch for the "Invalid Date" crash. ISO text saves you the trouble.


3. Single-Table Schemas Where Possible

What it does: For a small business site, resist the urge to normalize. One row per question with the reply inline. One row per pricing unit. No replies, no categories, no tags.

Why: The site has one admin. Threaded replies aren't coming. Categories add UI work and don't help the customer find anything (they're going to read 20 questions either way). Normalize only when the second use case forces your hand.

Pattern: Open lib/schema.ts — if it has more than 4 tables for a small business site, you're probably over-engineering.


4. Seed-on-First-Access for Static Data

What it does: Pricing units, business hours, "site info" rows are seeded into the DB lazily on first read. The static source of truth lives in data/pricing.ts (or similar), and the API seeds from it when the table is empty.

Why: Lets the developer keep the canonical data in a version-controlled TS file AND lets the admin edit it via the dashboard. No migration needed when a price changes.

Pattern:

export async function getPricingUnits() {
  let rows = await db.select().from(pricingUnits).all();
  if (rows.length === 0) {
    await db.insert(pricingUnits).values(PRICING_SEED);
    rows = await db.select().from(pricingUnits).all();
  }
  return rows;
}

Reference impl: rushroad_storage/lib/pricing.ts.


5. Cloudflare Workers Deploy via OpenNext

What it does: The Next.js app is built with the OpenNext adapter and deployed as a Cloudflare Worker (not Pages). Two equivalent deploy mechanisms exist; pick one per project and don't run both at once.

Why not Pages: wrangler pages deploy only uploads static assets — your Worker bundle gets ignored, and the site loads HTML with no JS/API routes. The OpenNext adapter creates a single worker that handles SSR + static assets together.

Mechanism A — Cloudflare Workers Builds (default for new client projects): Connect the GitHub repo to the Worker once via the Cloudflare dashboard. From that point, every push to the production branch auto-builds and auto-deploys. No GitHub Actions workflow, no CI secrets, no wrangler-action. The connection is a GitHub OAuth app installed on the agency or client GitHub account, surfaced in github.com/settings/installations as "Cloudflare Workers Builds." See §9a for setup. This is the new default.

Mechanism B — Manual wrangler deploy (fallback for hotfixes): From the dev laptop, npm run build:cf && npx wrangler deploy produces and uploads the Worker bundle in one go. Use this when Workers Builds is misbehaving or for one-off deploys that should not touch the production branch.

Anti-pattern — do not also create a Cloudflare Pages project "just in case." The dual setup (Worker + Pages) is silent confusion: both rebuild on push, only one owns the live domain, and the dashboard listing makes it look like the wrong one is connected. See §15.

wrangler.jsonc (or wrangler.toml) for Workers:

{
  "name": "rush-road-storage",
  "main": ".open-next/worker.js",
  "compatibility_date": "2026-06-01",
  "compatibility_flags": ["nodejs_compat"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  }
}

Required pieces: - main pointing at the Worker bundle. - assets.binding = "ASSETS" — required so the Worker can serve static files. The first deploy without this serves HTML but no CSS/JS. - nodejs_compat compatibility flag — required for jose, bcryptjs, and other Node-API-using libs.

open-next.config.ts minimum:

import { defineCloudflareConfig } from "@opennextjs/cloudflare/config";

export default defineCloudflareConfig({
  proxyExternalRequest: "fetch",
  edgeExternals: [],
  middleware: { external: true },
});
All three keys are required by @opennextjs/cloudflare v1.19+ — without them the build crashes with cryptic adapter errors.


6. NEXT_PUBLIC_* Vars Must Be at BUILD Time

What it does: Any env var prefixed with NEXT_PUBLIC_ (like NEXT_PUBLIC_TURNSTILE_SITE_KEY) is inlined into the JS bundle at build time. It is NOT read at runtime. Wrangler secrets are runtime-only.

Why this bit us: Turnstile widget wasn't rendering in production. wrangler secret put NEXT_PUBLIC_TURNSTILE_SITE_KEY set it at runtime, but the JS bundle had been built with the key as undefined, so the widget initialized with no site key and silently failed.

Source of truth depends on deploy mechanism:

  • Workers Builds (default): The build runs on Cloudflare's CI runner, so there is no GitHub Actions env to inject. Put public vars in a committed .env.production (NOT gitignored) at the repo root. Next.js reads it during opennextjs-cloudflare build and inlines the values into the client bundle. The Turnstile site key is public by design (it ships in HTML to every visitor) so committing is fine. The Turnstile secret key and every other non-public secret stay in .env (gitignored) locally and in wrangler secret put on the Worker.

  • GitHub Actions (fallback): Add the key as a GitHub Actions secret and inject it into the build step's env.

    - name: Build
      env:
        NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${{ secrets.NEXT_PUBLIC_TURNSTILE_SITE_KEY }}
      run: npm run build:cf
    

Quick test (either mechanism): After deploy, curl -s https://<site>/contact | grep -oE '/_next/static/chunks/[A-Za-z0-9_./~%-]+\.js' | sort -u to enumerate the JS chunks, then curl one of them and grep for the literal key value. If the literal value is in the bundle, you're set. If you see the env var name (NEXT_PUBLIC_TURNSTILE_SITE_KEY) uninlined, the build didn't have it.


7. Wrangler Secrets for Runtime Vars

What it does: All non-NEXT_PUBLIC env vars become wrangler secrets via wrangler secret put <KEY>.

Required for a Turso-based site (Rush Road–style):

wrangler secret put TURSO_DATABASE_URL
wrangler secret put TURSO_AUTH_TOKEN
wrangler secret put JWT_SECRET
wrangler secret put ADMIN_EMAIL
wrangler secret put ADMIN_PASSWORD_HASH
wrangler secret put TURNSTILE_SECRET_KEY
wrangler secret put RESEND_API_KEY
wrangler secret put DOMAIN

Required for a D1-based site (KFS-style):

wrangler secret put JWT_SECRET
wrangler secret put ADMIN_EMAIL              # only if using the env-var seed path; see admin-panel-checklist.md §1
wrangler secret put ADMIN_PASSWORD_HASH      # only if using the env-var seed path
wrangler secret put TURNSTILE_SECRET_KEY
wrangler secret put RESEND_API_KEY
wrangler secret put SQUARE_ACCESS_TOKEN      # if sending invoices
# D1 is configured via wrangler.jsonc (d1_databases), not secrets

Plus the build-time public var (NEXT_PUBLIC_TURNSTILE_SITE_KEY) set as a GitHub Actions secret per §6.


8. Custom Domain Setup

What it does: Map <domain> (the client's domain) to the Worker. There are two paths — pick the one that matches the client's situation.

Default path — buy the domain through Cloudflare Registrar (preferred): 1. Inside the client's Cloudflare account → Domain Registration → Register Domains → search and buy. Cost is at-cost with no markup; supports DNSSEC and free WHOIS redaction. The domain is added to the same account as the Worker with no DNS propagation, no third-party credentials to chase. 2. Workers & Pages → your Worker → Settings → Triggers → Custom Domains → Add <domain> (and www.<domain>). Done. The zone already exists in the same account, so DNS resolves immediately. 3. SSL/TLS → Edge Certificates → enable Always Use HTTPS.

Fallback path — client already owns the domain elsewhere (e.g., Namecheap): 1. Add <domain> to the client's Cloudflare account (Add Site flow). Cloudflare will issue two nameservers. 2. Ask the client to update nameservers at their existing registrar to the two Cloudflare nameservers. Wait for propagation (5 min – 48 hrs; Cloudflare emails when active). 3. Delete placeholder A records. Cloudflare auto-imports a few 127.0.0.1 A records during Add Site — these block Worker custom-domain setup. Delete them before the next step. 4. Workers & Pages → your Worker → Domains & Routes → Add → <domain>. Also add www.<domain> as a second custom domain. 5. SSL/TLS → Edge Certificates → enable Always Use HTTPS.

Nameserver gotcha (fallback path only): Some registrars (e.g., whois.com) put NS records in two different panels. The "DNS Management" panel doesn't allow editing nameservers — you have to go to "Domain Registration → Name Servers" instead.

Hygiene: if the client came in via the fallback path, consider transferring the domain to Cloudflare Registrar after launch (one-time fee equal to a year of registration, no renewal markup) so the agency no longer needs the registrar credentials for DNS edits.


9. Cloudflare Workers Builds (Default Auto-Deploy)

What it does: Push to main automatically builds and deploys the Worker. No .github/workflows/*.yml file is required — the connection lives entirely on Cloudflare's side.

Setup (one time per project): 1. Cloudflare → Workers & Pages → your Worker → Settings → Build → Connect GitHub → choose the repo and the production branch (main). 2. Set the build command to npm run build:cf and the deploy command to npx wrangler deploy. 3. Cloudflare installs a "Cloudflare Workers Builds" OAuth app on the GitHub account that owns the repo. Verify in github.com/settings/installations that the app has access to the right repo.

What you get for free: - Every push to the production branch → build + deploy in ~2-4 minutes. - Pull request previews: each PR gets a unique *.workers.dev URL posted as a PR comment, so reviewers can see the change before merge. - Build logs visible in the Cloudflare dashboard. - No GitHub Actions secrets, no CLOUDFLARE_API_TOKEN to manage, no workflow YAML.

Caveat for NEXT_PUBLIC_*: because the build runs on Cloudflare's runner (not in GitHub Actions), there is no Actions env to inject public vars from. Put them in a committed .env.production (NOT gitignored) — see §6. The Turnstile site key is public; the secret key and every other runtime secret go via wrangler secret put (see §7).

Fallback when Workers Builds is misbehaving: npm run build:cf && npx wrangler deploy from the dev laptop, or use the §9a GitHub Actions path.


9a. GitHub Actions Auto-Deploy (Alternative)

When to use this instead of Workers Builds: - You want the build to run on your own runner (e.g., for a custom build step Cloudflare's runner doesn't support). - You have an existing org-wide CI setup that you'd rather not split. - You want PR previews to post on a self-hosted comment bot rather than Cloudflare's.

Pattern (.github/workflows/deploy.yml):

on: { push: { branches: [main] } }
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: npm ci
      - name: Build
        env:
          NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${{ secrets.NEXT_PUBLIC_TURNSTILE_SITE_KEY }}
        run: npm run build:cf
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: deploy --config wrangler.toml

Required GitHub secrets: - CLOUDFLARE_API_TOKEN — Cloudflare dashboard → My Profile → API Tokens → Create → "Edit Cloudflare Workers" template - CLOUDFLARE_ACCOUNT_ID — Cloudflare dashboard → right sidebar of any zone - Every NEXT_PUBLIC_* var that needs to be in the bundle

Do not run Workers Builds and GitHub Actions deploy on the same Worker at the same time. Pick one. If both fire on a push they will race for the same deploy slot and produce confusing double-deploys.


10. Local Dev vs Prod Database

Turso (per §1a): Two Turso databases — local file for dev, cloud DB for prod. .env.local has TURSO_DATABASE_URL=file:./local.db so drizzle-kit push and the dev server hit the file. Production wrangler secret points at the cloud URL.

D1 (per §1b): opennextjs-cloudflare dev stubs the D1 binding so lib/db.ts works locally with the same API. For destructive ops (trim, seed, reset), use wrangler d1 execute <db-name> --local against the dev DB. For prod, use wrangler d1 execute <db-name> --remote — explicit, no env file in the loop.

Why split dev/prod regardless of DB choice: Faster dev iteration, no risk of a --force or DELETE script blowing away prod data because you forgot which env was loaded.

Important — verify before destructive ops: Before running any data-destructive script (trim, seed, reset), check which DB the script will hit. The Rush Road team trimmed the wrong DB once because .env.local was loaded by default. Pattern:

console.log("About to operate on:", process.env.TURSO_DATABASE_URL ?? '<d1-binding>');
if (!confirm("Continue?")) process.exit(0);


11. WAF Rate Limiting on Auth Endpoints

What it does: Before go-live, Cloudflare WAF rate-limits brute-force-vulnerable endpoints: - /api/auth/login — block more than 5 req/min/IP - /api/auth/forgot-password — block more than 5 req/min/IP (prevents email-spam abuse)

Setup: Cloudflare → your domain → Security → WAF → Rate limiting rules → Create rule.

Note from Rush Road: Account-level WAF requires Enterprise. Domain-level WAF rate limiting is free on the Pro plan; on the free plan it's limited but workable for low-volume sites. Skip if traffic is tiny and revisit if abuse appears.


12. Turnstile Production Keys

What it does: Before go-live, replace the dev-only Turnstile keys (1x000… pass-always) with production keys scoped to the actual domain.

Steps: 1. Cloudflare → Turnstile → Add site → enter <domain> (and www.<domain>). 2. Copy the site key + secret key. 3. wrangler secret put TURNSTILE_SECRET_KEY with the new secret. 4. Update the GitHub Actions secret NEXT_PUBLIC_TURNSTILE_SITE_KEY to the new site key. 5. Re-deploy.

Test keys do not work on a real domain — they only pass on localhost. Forgetting this step is the most common "form is broken in prod" cause.


13. Resend Domain Verification

What it does: Resend → Domains → Add <domain> → add the SPF + DKIM CNAME records to Cloudflare DNS → wait for verification → flip the from address in lib/email.ts.

See email-checklist.md §9 for the from-address flip.


14. Production Bring-up Order

When taking a new client site live, do these in order. The end-to-end flow is also captured in §16 (staging → production workflow); this is the same thing as a checklist.

  1. Get the client's email (a Gmail is fine). The Cloudflare account is created using their email so the account is technically theirs from day one — we just hold the password during the build and hand it over at delivery. See §15.
  2. Create the Cloudflare account with the client's email; set a strong password that the agency holds until handover. Verify the email before proceeding.
  3. Create the GitHub repo (in the Bonbon Dino org for agency-owned projects, or in the client's own GitHub org if they have one).
  4. Set up the Worker in the client's Cloudflare account. Push an initial commit to the repo so the Worker has something to build.
  5. Connect GitHub via Workers Builds (§9). From this point, every push to main auto-deploys to the Worker's *.workers.dev preview URL. This is the staging surface — share it with the client for review, gated with Single-use Login or email restrictions during QA.
  6. Iterate, QA, get client sign-off on the preview URL. No DNS, no custom domain, no handoff yet.
  7. Buy the domain via Cloudflare Registrar (default path in §8) inside the client's Cloudflare account. Or, if the client already owns a domain elsewhere, use the fallback path in §8.
  8. Attach the custom domain to the Worker (one click in §8). The git push loop now deploys to the real domain.
  9. Production Turnstile keys scoped to the new domain (§12).
  10. Resend domain verification + flip the from-address (noreply@<domain>).
  11. Always Use HTTPS enabled.
  12. (Optional) WAF rate limiting on /api/auth/login + /api/auth/forgot-password.
  13. Handover: change the Cloudflare account password, send it to the client along with a list of every service we've set up (Cloudflare, Resend, Turnstile, GitHub, any third-party API like Square/Stripe). For each, confirm the credentials live in the client's hands.
  14. Smoke test on the real domain: load homepage, submit the contact form, admin login, password reset, run a test invoice if applicable.

15. Single Repo, Single Deploy, Per-Client Cloudflare Account

What it does: Web and API code live in one repository, deployed as one Cloudflare resource. The browser sees the frontend and the API on the same origin — no cross-origin requests, no CORS dance, no baked-in API base URL.

Why same-origin matters: A cross-origin API call from a SPA requires (a) the frontend knows the API URL at build time (baked into the bundle) and (b) the API permits the frontend's origin via CORS, including Access-Control-Allow-Credentials: true if cookies are involved. Miss either and the browser fails the request with Failed to fetch — no body, no status, just a network error. Same-origin avoids all of this: cookies are first-party, the API URL doesn't need to be configured, and there's nothing to forget.

Default pattern: Next.js + OpenNext deployed as a single Worker (see §5). API routes are co-located in app/api/. One Cloudflare resource. One GitHub repo. One auto-deploy.

Account model — one Cloudflare account per client: - The Cloudflare account is created using the client's email (a Gmail is fine). The agency holds the password during the build and hands it over at delivery. This means the account is technically the client's from day one — we are not "transferring ownership" at the end, we are sharing credentials we already had. - The client is not required to know what Cloudflare is. Many are non-technical. The handoff is "here's your email + password, here's the list of services we set up." - If the client is technical and prefers to create the account themselves, that's fine — just have them add the agency email as a collaborator before we start. - The agency's own Bonbon Dino GitHub org holds repos for projects whose lifecycle is agency-managed; repos for client-owned or post-handover projects live in the client's own GitHub org. Either way, Workers Builds attaches to whichever org actually owns the repo.

Acceptable alternative for SPA-first projects: Vite SPA + Cloudflare Pages with functions/api/[[path]].js running Hono on the Pages Functions runtime. Same origin, same deploy. The frontend's api() helper uses an empty API_BASE in production and falls back to http://localhost:8787 only in dev when wrangler dev is listening.

Anti-pattern — do not also create a Cloudflare Pages project "just in case." The dual setup (Worker + Pages) is silent confusion: both rebuild on push, only one owns the live domain, and the dashboard listing makes it look like the wrong one is connected (the Pages project shows a GitHub badge, the Worker's connection lives on its Settings tab). Pick one platform per project: Workers for full-stack Next.js apps, Pages only for simple static-ish sites. See debugging-lessons.md → Deploy / Build → Two apps with the same name in Cloudflare dashboard — one is a Pages orphan for the symptoms and the cleanup.

Anti-pattern (caused a real "Failed to fetch" login bug — see debugging-lessons.md → Deploy / Build → Failed to fetch on /api/auth/login from a deployed SPA): Two repos, two deploy targets — a Pages project for the SPA + a separate Worker for the API — connected only by an env var baked into the bundle. A missing or wrong VITE_API_BASE_URL silently falls back to http://localhost:8787 and the deployed site returns Failed to fetch on every auth call. The fix is structural, not env-var-shaped: consolidate.

Smell test before adding a second Cloudflare resource to a project: "Why does this need to be a separate Worker?" The only acceptable answer is "it has a fundamentally different runtime, schedule, or scale profile." Two resources for "frontend" + "backend" is almost always wrong.


16. End-to-End Workflow: Staging on *.workers.dev → Custom Domain at Handover

What it does: A new client project is built, reviewed, and approved on the Worker's auto-assigned *.workers.dev preview URL. The custom domain is attached only after the client signs off. Push-to-deploy (via Workers Builds, §9) is wired up from the start, so the agency's iteration loop and the client's sign-off loop are the same surface — the URL just changes from *.workers.dev to the real domain at the end.

Why this works: - The client never sees a half-built site on their own domain. They only see a "finished" custom domain URL at delivery. - The agency can show progress on a stable preview URL while the client finalizes DNS or makes decisions. - Workers Builds gives you "git push = deploy to a stable preview" for free, with no CI/CD config, no GitHub Actions secrets, no wrangler-action version pinning. - The handover is essentially zero: the client already owns every account (Cloudflare, GitHub, registrar or Cloudflare Registrar) from step 1, so at delivery we are just sending them a password, not transferring anything.

Steps (mirrors §14 but framed as a single end-to-end story):

  1. Get the client's email. A Gmail is fine. The Cloudflare account is created using this email so the account is technically the client's from day one.
  2. Create the Cloudflare account with the client's email. Set a strong password; the agency holds it during the build and hands it over at delivery. Verify the email.
  3. Create the GitHub repo (agency's Bonbon Dino org for agency-managed projects; client's own GitHub org otherwise). Push an initial commit.
  4. Create the Worker in the client's Cloudflare account, connected to the GitHub repo via Workers Builds (§9). Set the build command to npm run build:cf and the deploy command to npx wrangler deploy. Public vars go in committed .env.production (§6); runtime secrets go via wrangler secret put (§7).
  5. The Worker now has a *.workers.dev URL. This is the staging surface. Restrict access (Cloudflare → Worker → Settings → "workers.dev" → Single-use Login or email allowlist) during QA so the public cannot see the work-in-progress.
  6. Iterate and share the URL with the client. Every push to main rebuilds and deploys in ~2-4 minutes. Use Workers Builds PR previews to get a *.workers.dev URL on each PR for internal review before merging.
  7. QA and sign-off on the preview URL. Same-origin, same domain as production will be — no surprises on cookies, CORS, or routing.
  8. Buy the domain through Cloudflare Registrar inside the client's Cloudflare account (default path in §8). The domain is added to the same account with no DNS propagation. If the client already owns a domain elsewhere, use the fallback path in §8.
  9. Attach the custom domain to the Worker (one click in §8). The git push loop now deploys to the real domain. Remove the preview-URL access restrictions.
  10. Production-only steps: Turnstile production keys (§12), Resend domain verification + from-address flip (§13), Always Use HTTPS (§8 step 5), optional WAF rate limiting (§11).
  11. Handover: change the Cloudflare account password, send it to the client along with a list of every service set up (Cloudflare, Resend, Turnstile, GitHub, any third-party API like Square/Stripe). For each, confirm the credentials live in the client's hands. For agency-owned repos in the Bonbon Dino GitHub org, transfer the repo to the client's own GitHub org as part of the handover.

What you do NOT do: - Do not buy the domain on Namecheap (or any third-party registrar) when Cloudflare Registrar can sell it. Avoids a separate set of credentials and a separate NS-propagation step. - Do not ask the client to add the agency as a Cloudflare collaborator unless they are technical and prefer to create the account themselves. The "we use their email" path is simpler for non-technical clients. - Do not also create a Cloudflare Pages project "just in case." It will rebuild on every push, never serve anything, and confuse the next person who looks at the dashboard (see §15 anti-pattern). - Do not run npm run deploy:cf from the dev laptop as the primary deploy loop — that's what Workers Builds is for. Reserve manual deploy for hotfixes when Workers Builds is misbehaving.

Smell test for "is this project following the right workflow?": - Is there exactly one Cloudflare app per project in the dashboard? (One Worker, no orphan Pages project.) - Is the GitHub connection on the Worker's Settings tab, not on a separate Pages project? - Did the agency hold a Cloudflare password during the build and hand it over at delivery? - Did the custom domain attach at the end, not the start?

If any answer is no, something is off — refer back to §15 for the structural fix.


Quick Checklist — "Is the DB + deploy done?"

  • [ ] Database: Turso + Drizzle (lib/schema.ts is the source of truth) or D1 + raw prepare() in lib/db.ts. Pick one per project (§1).
  • [ ] D1 only: getDb() guards env.DB against undefined and throws a clear error (§1b)
  • [ ] All dates stored as ISO 8601 text (not int)
  • [ ] Schema kept minimal — no premature normalization
  • [ ] Static data (pricing, etc.) seeded on first read from data/*.ts
  • [ ] Deploy target: Cloudflare Worker via OpenNext, NOT Pages
  • [ ] Auto-deploy: Cloudflare Workers Builds (§9) connected to the GitHub repo on the Worker's Settings tab — or, if intentionally using the alternative, GitHub Actions with cloudflare/wrangler-action (§9a). Not both.
  • [ ] wrangler.jsonc has main + assets.binding = "ASSETS"
  • [ ] open-next.config.ts includes proxyExternalRequest, edgeExternals, middleware
  • [ ] nodejs_compat compatibility flag set
  • [ ] NEXT_PUBLIC_* vars: in committed .env.production if using Workers Builds (§6), in GitHub Actions secrets if using §9a
  • [ ] All runtime secrets set via wrangler secret put
  • [ ] Cloudflare account created using client's email; password held by agency during build, changed and handed over at delivery (§15)
  • [ ] One Cloudflare app per project — Worker only, no orphan Pages project (§15 anti-pattern)
  • [ ] Custom domain: bought via Cloudflare Registrar (§8 default) OR nameservers switched + placeholder A records deleted (§8 fallback)
  • [ ] Custom domain wired to Worker
  • [ ] Always Use HTTPS enabled
  • [ ] Local dev uses a separate DB from prod (Turso: file:./local.db in .env.local; D1: opennextjs-cloudflare dev stubs the binding). Verify which DB before any destructive script (§10).
  • [ ] Production Turnstile keys (not 1x000… test keys)
  • [ ] Resend domain verified; from-address flipped to noreply@<domain>
  • [ ] Web + API in one repo, one Cloudflare resource (see §15)
  • [ ] (Optional) WAF rate limiting on /api/auth/login + /api/auth/forgot-password

17. Manual Deploy with Local API Token

When to use: First-time deploy of a new project before Workers Builds (§9) is connected, or for a one-off hotfix deploy from the dev laptop.

Pattern: 1. Store the Cloudflare API token in .env.local (gitignored — never commit):

CLOUDFLARE_API_TOKEN=<token>
2. Build and deploy:
npm run build:cf
CLOUDFLARE_API_TOKEN=<token> npm run deploy
3. Set runtime secrets immediately after:
printf '%s' "<value>" | CLOUDFLARE_API_TOKEN=<token> npx wrangler secret put TURNSTILE_SECRET_KEY
printf '%s' "<value>" | CLOUDFLARE_API_TOKEN=<token> npx wrangler secret put CONTACT_EMAIL
printf '%s' "<value>" | CLOUDFLARE_API_TOKEN=<token> npx wrangler secret put RESEND_API_KEY

API token permissions (use "Edit Cloudflare Workers" template): Includes Workers Scripts:Edit, Workers Routes:Edit, Workers KV Storage:Edit, Account Settings:Read. Set Account Resources to the client's account; Zone Resources to the client's zone.

Critical: Workers Scripts:Edit is the permission that breaks deploys when missing — learned from Keilen Family Services first deploy (failed with code 10000 until added).


18. Turnstile Hostname for Staging (workers.dev)

What it does: Turnstile widgets are scoped to specific hostnames. A widget created for kobebellevue.us will silently fail on kobe-bellevue.koberestaurantbellevue.workers.dev — the form renders but the widget errors on verification.

Why this matters: During the staging phase (§16 step 5–6), the site runs on a *.workers.dev URL. Without adding that hostname to the Turnstile widget, you cannot test the contact form on staging.

Pattern: 1. After the first npm run deploy, note the workers.dev URL in the output (e.g. https://kobe-bellevue.koberestaurantbellevue.workers.dev). 2. Cloudflare dashboard → Turnstile → your widget → Settings → Add Hostname. 3. Add the full workers.dev subdomain (e.g. kobe-bellevue.koberestaurantbellevue.workers.dev). 4. A single Turnstile widget supports up to 10 hostnames — add both the staging and production hostnames to the same widget.

At production cutover: No Turnstile change needed if you already added kobebellevue.us when creating the widget. Just remove the workers.dev hostname after go-live if you want to tighten scope.


How to extend this file

  • This is a living document — when something is stale, rewrite it in place. Append-only was the old rule and it has been retired; outdated content is worse than no content.
  • Match the house style: numbered section, What it does / Why / Pattern. Code snippets where they help. Cross-reference other sections by §N instead of duplicating.
  • When a section is superseded by a different approach (e.g., a new deploy mechanism, a different account model), prefer rewriting the existing section to reflect the new default. If the old approach is still useful for some projects (e.g., §9a GitHub Actions as an alternative to §9 Workers Builds), keep it as a separate numbered subsection.
  • One concrete improvement per section. Don't bundle three loosely-related lessons into one §.