Phase 2 runbook — wire OpenNext's cache on Cloudflare (#445)
Revised 2026-10-27 after the deep analysis and local verification in
2026-10-26-cache-guest-reads-phase2-plan.md (Polish; §2 explains every step
below). The previous version of this runbook wired a Durable Object queue and
the bare D1 tag cache; both were wrong for this app (see “What changed”).
Phase 1 (PR #532) shipped the code: the user-independent reads run through
unstable_cache (120 s TTL + tags) on a cookie-free anon client. Under OpenNext’s
default dummy caches that is a no-op — every call still reads Supabase.
Phase 2 gives OpenNext a real incremental cache (R2) and tag cache (D1).
Who runs what: the owner creates the Cloudflare resources (steps 1–2); the
agent prepares the config PR (step 3) once it has the D1 database_id; the owner
merges it while watching the build; both verify (step 5). Deploy stays Workers
Builds from master.
Versions in repo: @opennextjs/cloudflare 1.20.13, next 16.4.3, wrangler 4.152.
What changed against the first draft
- No queue / Durable Object.
unstable_cacherevalidates a stale entry in the background through Next itself (pendingRevalidates→waitUntil); OpenNext’s queue only serves ISR pages, which this app has none of (every route reads cookies). Verified locally: a stale entry was served at once and refreshed in the background with the default (dummy) queue. So: noNEXT_CACHE_DO_QUEUE, nomigrations, noWORKER_SELF_REFERENCE, noDOQueueHandlerexport incustom-worker.ts. - Soft-tag filter is mandatory.
revalidatePath('/', 'layout')(retry, dismiss, notification actions) writes the soft tag_N_T_/layout, which every page’s entries are checked against — without the filter each such click purges the whole data cache for everyone. Verified locally, and fixed bywithFilter({ tagCache: d1NextTagCache, filterFn: softTagFilter }). - The D1 table is created by hand.
populateCachepicks its D1 step by the tag cache’s name; the filtered wrapper is named differently, so therevalidationstable is not created. Without it reads fail open andrevalidateTagwrites are lost silently (verified locally). Step 2.3. - Every deploy starts with an empty cache: the build id is part of every R2, Cache API and D1 key. Old builds’ R2 objects need a lifecycle rule (step 1.2).
- Smart Placement does not help here (
cf-placement: local-*, #464 / #545), and the Worker size limit is 64 MiB uncompressed on both plans (no 3 MiB limit). - Prerequisite in code: the Inngest extract job purges the caches after it writes a recipe (#551). Merge it before step 3.
Step 0 — Before you start (owner)
- Workers Builds → Settings → Deploy command: expect
pnpm exec opennextjs-cloudflare deploy(ornpx wrangler deploy, which wrangler 4.152 hands to OpenNext). Either runspopulateCache, which uploads the few build-time entries to R2 — the default Workers Builds token has Workers R2 Storage: Edit, which is enough. D1 permissions are not needed by populate (the filtered tag cache skips it). - The free plan is enough (plan §2.9). Durable Objects are not used.
Step 1 — R2 bucket for the incremental cache (owner, once)
# 1.1 — separate from the BACKUPS bucket; EU, close to Supabase and the usersnpx wrangler r2 bucket create zapiszprzepis-inc-cache --location weur1.2 — Lifecycle rule so old builds’ entries do not pile up: dashboard → R2 →
zapiszprzepis-inc-cache → Settings → Object lifecycle rules → prefix
incremental-cache/, delete objects 14 days after upload.
Step 2 — D1 database for the tag cache (owner, once)
# 2.1 — note the database_id it prints (not a secret; it goes into wrangler.jsonc)npx wrangler d1 create zapiszprzepis-tag-cache --location weur
# 2.2 — hand the database_id to the agent for step 3
# 2.3 — the table populateCache will NOT create (see "What changed"); idempotentnpx wrangler d1 execute zapiszprzepis-tag-cache --remote --command \ "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, stale INTEGER, expire INTEGER default NULL, UNIQUE(tag) ON CONFLICT REPLACE);"
# 2.4 — check: prints 0npx wrangler d1 execute zapiszprzepis-tag-cache --remote --command "SELECT count(*) FROM revalidations"The resources must exist before step 3 merges (BACKWARD_COMPATIBILITY §6, CODE_REVIEW.md): wrangler refuses a binding to a missing bucket or database.
Step 3 — Config PR (agent prepares, owner merges)
wrangler.jsonc — add to the existing config (keep BACKUPS, IMAGES, ASSETS,
ratelimits, triggers, vars). Binding names are fixed by OpenNext:
"r2_buckets": [ { "binding": "BACKUPS", "bucket_name": "zapiszprzepis-backups" }, { "binding": "NEXT_INC_CACHE_R2_BUCKET", "bucket_name": "zapiszprzepis-inc-cache" }],"d1_databases": [ { "binding": "NEXT_TAG_CACHE_D1", "database_name": "zapiszprzepis-tag-cache", "database_id": "<from step 2.1>" }]open-next.config.ts (the exact config verified locally):
import { defineCloudflareConfig } from "@opennextjs/cloudflare"import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache"import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache"import { softTagFilter, withFilter } from "@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter"
export default defineCloudflareConfig({ // A per-location Cache API copy (kept for the entry's 120 s) in front of R2. incrementalCache: withRegionalCache(r2IncrementalCache, { mode: "long-lived" }), // Soft tags (_N_T_…) out: revalidatePath('/', 'layout') must not wipe the data cache. // Side effect: populateCache no longer creates the D1 table — created by hand (runbook 2.3). tagCache: withFilter({ tagCache: d1NextTagCache, filterFn: softTagFilter }),})No queue, no enableCacheInterception, no custom-worker.ts change.
Before merging:
- validation gate (
pnpm typecheck,pnpm lint,pnpm test); pnpm cf-typegenif the new bindings should appear incloudflare-env.d.ts;pnpm previewwith the table created locally (npx wrangler d1 execute NEXT_TAG_CACHE_D1 --local --command "<2.3 SQL>"): a repeat guest request to/makes no Supabase call,revalidatePath('/', 'layout')leaves the cache warm,revalidateTag('recipes:list', { expire: 0 })empties it.
Merge when the owner can watch the Workers Build. If it fails (e.g. populate lacks a permission), the previous version stays live — fix and Retry.
Step 4 — Deploy
Merging deploys. In the Workers Build log expect “Populating remote R2 incremental cache … Successfully populated”. There is no “Creating D1 table” line — expected.
Step 5 — Verify on production
pnpm smoke:prodexits 0 (mandatory after any deploy or binding change, CLAUDE.md).- Hits: repeat guest requests to
/,/przepisy/obiadyand three recipe pages, from Poland and from US East: the first is a miss, the repeats are faster. Workers Logs: no[recipe-cache]errors, no “Failed to revalidate tag”. - Invalidation: the owner refreshes one of their recipes („Odśwież”). The
new content shows at once, and D1 has a fresh row:
npx wrangler d1 execute zapiszprzepis-tag-cache --remote --command "SELECT tag, revalidatedAt FROM revalidations ORDER BY revalidatedAt DESC LIMIT 5". Never add recipes as a QA account. - TTFB before/after (median of 10, guest, three page types, Poland + US East), posted on #445 and #545.
- 48 h watch: R2 and D1 usage in the dashboard against the free limits (plan §2.9); errors in Workers Logs.
Rollback
- Fastest: Cloudflare → Workers & Pages → zapiszprzepis → Deployments → roll back to the previous version (no build).
- Durable: revert the step 3 PR —
open-next.config.tsback todefineCloudflareConfig({})means the dummy caches, i.e. today’s behavior. - No data to migrate back; the bucket and the database can stay or be deleted.
Known limits / open checks
- Cold cache after every deploy (build id in the keys). Accepted for now; a post-deploy warm-up from the sitemap is plan stage F, only if step 5.4 shows it matters.
- Uncached reads remain on the recipe page (
recipe_category_context,dish_versions, similar recipes),category_descriptionsand the whole profile page — plan stage E, for crawler load time outside the EU (#545). - D1 calls have no timeout; a slow D1 slows every cached read. D1 is in
weur; watch step 5.4. - Failure mode is fail-open: an R2/D1 error or a free-plan daily limit never 500s a page — it costs a cache miss or a lost invalidation (bounded by the TTL).
revalidateTaginside the Inngest step (#551) is expected to have Next’s request context (it runs inside the/api/inngestrequest); step 5.3 proves it. If not, the job logs[recipe-cache] invalidation failedand the TTL applies.- Extra cost: none on the free plan at this scale; R2 and D1 usage stays well inside the free tiers (plan §2.9).