---
name: cloudflareops
description: Any Cloudflare operation — Workers, wrangler, secrets, R2/D1/KV/Hyperdrive, cron triggers, Queues, Pages-vs-Workers, Images, log tailing, capacity/latency on bundled plan. Triggers on "deploy worker", "wrangler", "cloudflare", "R2", "D1", "KV", "worker secret", "cron trigger", "queue costs".
---

# Cloudflare Ops

Quick-ref for Cloudflare work. **Read before asking user to "run dashboard step"** — most automatable.

For publication/deploy, MUST follow `/home/user/Projects/0 DOCS/GIT_FATIGUE.md` §12. Agents MUST NOT run raw `wrangler deploy`; delivery controller invokes deploy primitive against receipt-backed artifact. User surface MUST be only `Preview ready` or `Needs product decision`.

## TL;DR decision matrix

| You want to... | Use this |
|---|---|
| Deploy a Worker | Submit receipt-backed artifact to delivery controller |
| Push a secret | `wrangler secret put <KEY> [--env <env>]` (stdin) |
| Bulk push secrets | `wrangler secret bulk [json]` |
| Create R2 bucket | MCP `r2_bucket_create` OR `wrangler r2 bucket create <name>` |
| Create D1 database | MCP `d1_database_create` OR `wrangler d1 create <name>` |
| Run D1 SQL | MCP `d1_database_query` OR `wrangler d1 execute` |
| Create KV namespace | MCP `kv_namespace_create` OR `wrangler kv namespace create` |
| List Workers/accounts/buckets/DBs/KVs | MCP `*_list` tools |
| Stream Worker logs | `wrangler tail <worker>` |
| Trigger cron manually | `wrangler cron trigger --cron "<pattern>"` |
| Create Hyperdrive config | MCP `hyperdrive_config_edit` OR `wrangler hyperdrive create` |
| Search CF docs | MCP `search_cloudflare_documentation` |
| Check current user/account | `wrangler whoami` |
| Switch active account in MCP | MCP `set_active_account` |

---

## Authentication state

**Wrangler uses OAuth** (not API token). Token at `~/.config/.wrangler/config/default.toml`. Rotates ~1h (`expiration_time` field).

### Scopes OAuth token carries (verified)
```
account:read  user:read  workers:write  workers_kv:write  workers_routes:write
workers_scripts:write  workers_tail:read  d1:write  pages:write  zone:read
ssl_certs:write  ai:write  ai-search:write  ai-search:run  queues:write
pipelines:write  secrets_store:write  containers:write  cloudchamber:write
connectivity:admin  offline_access
```

### Scopes NOT carried
- `images:*` — Cloudflare Images, Image Transformations
- `stream:*` — Cloudflare Stream
- `access:*` / `zero_trust:*` — Access / Zero Trust
- `email:*` — Email Routing
- `user:tokens:edit` — can't create API tokens programmatically
- `dns:edit` / full `zone:edit` — read zones only, can't edit DNS
- `billing:*`, `logs:*` — no billing or Logpush

---

## What each surface can do

### Cloudflare MCP (26 tools)
```
accounts_list  set_active_account
workers_list  workers_get_worker  workers_get_worker_code
r2_buckets_list  r2_bucket_create  r2_bucket_delete  r2_bucket_get
kv_namespaces_list  kv_namespace_create  kv_namespace_delete  kv_namespace_get  kv_namespace_update
d1_databases_list  d1_database_create  d1_database_delete  d1_database_get  d1_database_query
hyperdrive_configs_list  hyperdrive_config_edit  hyperdrive_config_get  hyperdrive_config_delete
migrate_pages_to_workers_guide  search_cloudflare_documentation
```

**Covers:** Accounts, Workers (list/get — NO deploy), R2, KV, D1, Hyperdrive, docs search.

**Does NOT cover:** Deploy, secrets, cron, tail, Queues, Vectorize, Pages, DNS, Images, Transformations, Email, Access, Workflows, Pub/Sub.

### wrangler CLI (v3.114+)
```
init/dev/deploy/deployments/rollback/versions/triggers/delete/tail
secret(put|delete|list|bulk)  kv  queues  r2  d1  vectorize  hyperdrive
cert  pages  ai  workflows  mtls-certificate  pubsub  dispatch-namespace
login/logout/whoami
```

**Covers:** Deploy, secrets, cron, logs, rollbacks, R2, D1, KV, Queues, Vectorize, Hyperdrive, Pages, Workflows, AI, mTLS, Dispatch.

**Does NOT cover:** Cloudflare Images, Stream, DNS records, Access/ZT, Email Routing, creating API tokens, enabling/disabling paid products.

### REST API with wrangler OAuth token
```bash
TOKEN=$(grep oauth_token ~/.config/.wrangler/config/default.toml | awk -F'"' '{print $2}')
ACCT=$(pnpm exec wrangler whoami 2>&1 | grep -oE '[a-f0-9]{32}' | head -1)
```

| Endpoint | Result |
|---|---|
| `GET /accounts/:a/workers/scripts` | ✅ 200 |
| `GET /accounts/:a/d1/database` | ✅ 200 |
| `GET /accounts/:a/storage/kv/namespaces` | ✅ 200 |
| `GET /accounts/:a/r2/buckets` | ✅ 200 |
| `GET /accounts/:a/queues` | ✅ 200 |
| `GET /accounts/:a/ai/models/search` | ✅ 200 |
| `GET /accounts/:a/pages/projects` | ✅ 200 |
| `GET /zones` | ✅ 200 (read only) |
| `GET /accounts/:a/images/v1` | ❌ 403 `code:10000` |
| `GET /accounts/:a/stream` | ❌ 403 `code:10000` |
| `GET /accounts/:a/access/organizations` | ❌ 403 `code:10000` |
| `GET /accounts/:a/email/routing` | ❌ 404 `code:10001` |
| `GET /user/tokens` | ❌ 403 `code:9109` |

Out-of-reach options: (1) `wrangler logout && wrangler login` to refresh scopes, (2) scoped API token from dashboard, (3) different authenticated session.

---

## What ALWAYS requires dashboard clicks

- **Subscribe paid products** (Images, Stream, Workers Paid, Access) — billing consent required
- **Enable Image Transformations** — `Speed → Optimization → Image Transformations`, one click, free, per zone
- **Create API token** with custom scope — Profile → API Tokens → Create Token
- **Connect custom domain** — wrangler creates route, but new zone DNS needs zone setup
- **Register new domain** via Cloudflare Registrar

---

## Idiomatic recipes

### Deploy a Worker

Submit valid receipt-backed artifact + target environment to delivery controller. Missing receipt → designated executor runs canonical build/gate once. Controller invokes `wrangler deploy` primitive.

### Push secrets from .env (bulk)
```bash
jq -Rs 'split("\n") | map(select(length>0 and startswith("#") | not))
       | map(capture("^(?<k>[^=]+)=(?<v>.*)$")) | from_entries' .env \
  | pnpm exec wrangler secret bulk --env preview
```
One at a time: `printf '%s' "$VALUE" | pnpm exec wrangler secret put KEY --env preview`

### Create R2 bucket + bind
```bash
pnpm exec wrangler r2 bucket create my-bucket
```
```toml
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "my-bucket"
```

### Cron triggers
```toml
[triggers]
crons = ["* * * * *", "0 0 1 * *"]
```
Manual: `pnpm exec wrangler cron trigger --cron "* * * * *"`

### Worker + static assets
```toml
main = "./dist/_worker.js/index.js"
[assets]
directory = "./dist"
binding = "ASSETS"
```
Create `public/.assetsignore` (framework copies to dist):
```
_worker.js
_routes.json
```

### Env inheritance — ALWAYS bites
Top-level `[[r2_buckets]]`, `[triggers]`, `[assets]`, `compatibility_date` **NOT inherited** by `[env.preview]`. Redeclare per env:
```toml
[env.preview]
name = "myapp-preview"
compatibility_date = "2026-04-01"
compatibility_flags = ["nodejs_compat"]

[env.preview.assets]
directory = "./dist"
binding = "ASSETS"

[[env.preview.r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "myapp-uploads-preview"

[env.preview.triggers]
crons = [...]

[env.preview.vars]
ENVIRONMENT = "preview"
```

### Smart Placement for DB-bound Workers
```toml
[smart_placement]
mode = "smart"
```
Routes requests to PoP closest to external DB (Neon, Postgres, etc.). Cuts DB round-trip ~50ms. Only helps Workers with synchronous DB calls.

---

## Gotchas catalogue

1. **`${{ ... }}` in GH Actions `run:` blocks** — parser flags as command injection. Use env block: `env: KEY: ${{ secrets.FOO }}`, then `run: echo "$KEY"`.
2. **Wrangler 3→4 upgrade** — `assets` config syntax changed. "Unknown argument" → check `pnpm dlx wrangler@4 versions`.
3. **KV "SESSION" binding warning** (Astro adapter) — adapter tries to wire KV for built-in sessions. If sessions live elsewhere: `sessionKVBindingName: undefined` in adapter options.
4. **`optimizeDeps` + drizzle in dev mode** — Vite module runner can't see `inArray`/`eq`/etc. Fix:
   ```js
   vite: {
     ssr: { noExternal: ['drizzle-orm'] },
     optimizeDeps: { exclude: ['drizzle-orm', '@neondatabase/serverless'] },
   }
   ```
5. **Cron triggers** land on top-level script only. Redeclare under `[env.<name>.triggers]` for envs.
6. **`wrangler deploy` exposing `_worker.js`** — use `.assetsignore` recipe above.
7. **R2 bucket region** — CF auto-picks near account. EU-only data: `--jurisdiction eu` (paid plan required).
8. **`wrangler secret put` stdin-only** — `printf '%s' "$VALUE" | wrangler secret put KEY`. Never `echo` (trailing newline). Never pass value on CLI (leaks to `ps aux`).
9. **OAuth token expires ~1h** — `expiration_time` in wrangler config. Long-running script → 401 → `wrangler whoami` to refresh.
10. **`workers.dev` subdomain** = `{name}.{random-slug}.workers.dev` — only printed by `wrangler deploy`. Capture from output.
11. **KV quota math is the binding constraint** — free: 100k reads/day + 1k writes/day. Paid: 10M reads/day + 1M writes/day. Per-request KV read = RPS × 86400. At 10 RPS: 864k reads/day (exhausts free tier in hours). At 100 RPS: 8.6M/day (approaches paid ceiling). **Storage preference order: R2 → DO state → `cache.default` → external DB → KV.** KV last resort: infrequent global state (feature flags, config) changing daily or less. Before adding KV cache: run the math — writes/day = write_rate × 86400.
12. **Framework adapters own the Worker entry point** — Astro/SvelteKit/OpenNext compile to managed `_worker.js`. Any `scheduled()` export in app source is silently discarded. Put cron handlers in a separate bare Worker.
13. **Framework build generates its own wrangler config** — Astro outputs `dist/server/wrangler.json`, OpenNext similar. Always deploy from the generated config, not the source `wrangler.toml`. Source toml = bindings declaration; built output = what ships.
14. **Edge-cached HTML goes stale after deploy** — `cache.default` keys by URL only. Cached response may reference dead asset hashes → FOUC. Fix: include `BUILD_ID` (git SHA or deploy timestamp) in HTML cache key. Static assets: content-addressed URLs → cache forever (`immutable`). SWR grace period on HTML.
15. **Probe ≥20× before declaring failure** — 200+ CF PoPs. Single `curl` hits one warm PoP = misleading. Always cache-bust:
    ```bash
    for i in $(seq 1 20); do
      curl -sf "https://your-worker.workers.dev/?cb=$RANDOM" -o /dev/null -w "%{http_code}\n"
    done
    ```
16. **Framework SSR bundles ship UNMINIFIED** — Vite-based frameworks (Astro incl.) minify client build only; server bundle burns ~25-35% of the 3072 KiB gzip Free-plan upload limit for nothing (measured: 3112.86 → 2288.48 KiB after minify). Fix + full rule in `/astro` skill → "Cloudflare Workers / Edge specific". Size check: `wrangler deploy --dry-run` → "Total Upload gzip".
    Look at distribution. A warm PoP returning 200 doesn't mean others are healthy.
16. **DO host deploy order** — Worker A binds DOs from Worker B → deploy B before A. Stale B = runtime binding error in A.
17. **CPU ceiling on bundled plan** — ~50ms CPU/request (Unbound/Standard: 30s; **Workers Free: 10ms**). Ceiling surfaces as `exceededCpu` in `wrangler tail`. Not always absolute — scheduler-slice contention under burst kills requests even when isolated CPU < ceiling (isolate holds state across long DB awaits; scheduler may grant thin slice on resume). In wrangler tail, killed requests show low `cpuTime` — that's CPU at time of kill, not the ceiling. Successful request p95 CPU shows real budget. Fix path: (1) edge-cache cold paths, (2) coalesce DB queries, (3) queue burst work, (4) DO render offload (free escape — see below). See **Scaling on the bundled plan** below.
18. **DO `stub.fetch()` response headers are IMMUTABLE** — `headers.set()` throws. Same for `cache.match()` responses. Wrap before mutating: `res = new Response(orig.body, orig)`.
19. **workerd method refs need `.bind`** — passing `cfCtx.waitUntil` (or any runtime ctx method) as bare callback loses `this` → "Illegal invocation" at runtime, invisible at typecheck. Always `.bind(cfCtx)` or wrap `(p) => cfCtx.waitUntil(p)`.
20. **Generated wrangler config drops DO migrations** — framework adapters (Astro, OpenNext) regenerate the deploy config (`dist/server/wrangler.json`) each build with `migrations: []`. Deploying raw = DO binding missing → silent fallback / runtime error. Deploy script must unconditionally re-patch the migration + binding post-build AND gate that the DO class name exists in the built entry (`grep -q "MyDO" dist/server/$MAIN || exit 1`).
21. **Custom entry escapes adapter-owned `_worker.js`** — gotcha 12's "separate bare Worker" is not the only option. Set `main = "src/worker.ts"` in wrangler.toml and re-export the adapter handler (Astro: `import { handle } from '@astrojs/cloudflare/handler'`) plus your own exports (`scheduled()`, DO classes) from one file. One Worker, full control of entry.
22. **Workers Cache front cache** (`"cache": {"enabled": true}`, wrangler ≥4.69, works on Free — validated 2026-07-09) — caches responses BEFORE the Worker; HIT = zero invocation. Worker opts responses in via `Cloudflare-CDN-Cache-Control` (+ `Cache-Tag` for tag purge); in-worker purge: `import { cache } from 'cloudflare:workers'` → `cache.purge({tags})`. Both headers are CONSUMED+STRIPPED by CF — absence in the client response ≠ code missing; verify via repeat-fetch same URL → `cf-cache-status: HIT` + incrementing `age`. Cookied requests bypass. Adapter-regenerated configs drop the `cache` key (gotcha 20 applies) — re-patch post-build. NEVER combine `s-maxage` with `stale-while-revalidate` in the CDN header block.

---

## Cloudflare Images vs Image Transformations

Two different products. Distinction matters.

### Cloudflare Images (storage + delivery)
- **Paid: $5/mo base + $1/100k deliveries + $5/100k stored**
- Stores originals; serves variants from `imagedelivery.net/{account-hash}/{image-id}/{variant}`
- Needs dashboard subscription + account-scoped API token (`images:*` scope). Not automatable via wrangler OAuth or MCP.

### Image Transformations (on-the-fly transforms)
- **Free on all plans.** Enable once: `Dashboard → Images → Transformations → Enable`. One click per zone.
- Doesn't store images. Transforms on-the-fly from R2, external URL, or any fetch source.
- Works from `workers.dev` — no custom domain required.

### Three ways to use Image Transformations

**1. `IMAGES` binding (recommended)**
```toml
[images]
binding = "IMAGES"
# Not inherited — repeat per env:
[env.preview.images]
binding = "IMAGES"
```
```ts
const r2Object = await env.R2_BUCKET.get(key);
const result = await env.IMAGES
  .input(r2Object.body)
  .transform({ width: 400, height: 400, fit: 'cover' })
  .output({ format: 'image/avif', quality: 75 });
return new Response(result.image(), {
  headers: { 'Content-Type': 'image/avif', 'Cache-Control': 'public, max-age=31536000, immutable' }
});
```

**2. `cf.image` fetch option (older, still works)**
```ts
const transformed = await fetch(sourceUrl, { cf: { image: { width: 400, format: 'avif' } } });
```

**3. `/cdn-cgi/image/<opts>/<url>` URL pattern** — requires custom domain. Avoid unless necessary.

### Image proxy recipe
Route: `/api/img/<variant>/<format>/<r2-key>` → fetch from R2 → transform via `IMAGES` binding → return with immutable headers.
- URL content-addressed by R2 key → safe `max-age=31536000, immutable`
- Path-traversal guard on R2 key (`..`, backslash)
- Fallback to R2 original on transform error
- Variant dimensions live in proxy AND `buildVariantUrl` helper — keep in sync

### Zero-cost fallback
Serve R2 originals unchanged. `<picture>` + `width`/`height` + `sizes` = responsive layout without transforms. Lose AVIF/WebP and per-breakpoint resize.

---

## Scaling on the bundled plan

Bundled plan: ~50ms CPU/request. Unbound/Standard: 30s (higher cost). **Exhaust code-level solutions first. Plan upgrade is valid but is step 7, not step 1. Engineering solutions are free; plan upgrades are recurring.**

### Decision order for capacity/latency problems

1. **Edge cache** — `cache.default` for stable URLs. Cache hit = near-zero CPU. First fix, biggest win.
2. **Coalesce DB fan-out** — N sequential queries → one CTE or `Promise.all`. DB round-trips dominate wall time and hold the isolate open.
3. **Single-flight dedup** — `Map<string, Promise>` in module scope. N concurrent cold-cache requests share one downstream fetch. Delete entry on resolve/reject.
4. **Queue burst work** — `Queues.send(jobId)` (sub-ms). Consumer handles async + retries + DLQ. Burst traffic → backpressure, not CPU spike.
5. **DO batching** — buffer high-frequency writes in DO in-memory state; flush on timer/threshold. N updates → one DO interaction.
6. **Smart Placement** — `mode = "smart"` routes invocations near DB. ~50ms round-trip saved → shorter wall time → less scheduler-slice risk.
7. **DO render offload** — when the expensive path (SSR, heavy aggregation) mathematically cannot fit the per-request CPU budget, move its EXECUTION into a Durable Object. See **Durable Object render offload** below. This is the structural fix when 1–6 only reduce miss *frequency* but the miss itself still blows the ceiling.
8. **Plan upgrade** — Unbound/Standard if CPU is still the ceiling after 1–7. Confirm with `wrangler tail` that `exceededCpu` is absolute ceiling (high `cpuTime` on killed reqs), not scheduler contention (low `cpuTime` on killed reqs vs high p95 on successes).

### Toolkit patterns

| Pattern | When | Implementation |
|---|---|---|
| **Edge cache + SWR** | Response stable >1s | `cache.default.put(req, resp.clone())`; SWR header; BUILD_ID in key for HTML |
| **Single-flight dedup** | High concurrency, cold-cache | `Map<string, Promise>` module-scope; return in-flight; delete on resolve/reject |
| **Fan-out coalesce** | N sequential DB queries | One CTE or `Promise.all([q1, q2, q3])` |
| **Queue backpressure** | Burst expensive work (email, LLM, writes) | Enqueue job ID; consumer handles async + retries + DLQ |
| **DO batching** | High-freq writes to single entity | Buffer in DO memory; flush on timer/threshold |
| **BUILD_ID cache key** | Stale HTML after deploy (FOUC) | Inject git SHA/deploy timestamp as Var; include in HTML cache key; static assets content-addressed |
| **DO render offload** | Miss path can't fit CPU budget at all | Cache miss → `stub.fetch()` to per-cache-key DO; DO re-invokes handler with 30s budget (see below) |

---

## Durable Object render offload (free-tier CPU escape)

**Key fact: SQLite-backed DOs are available on Workers FREE and get 30s CPU/request** — vs 10ms on the free Worker itself. 3000× headroom, $0. Shipped + proven on Multideal 2026-06-10: cold cache-buster SSR went from 4–12/20 503s (`exceededCpu`) to 20/20 200s.

**When**: caching/warming/diet only reduce miss *frequency*; if a single cold miss still can't fit the budget (CPU profile says DB client init + queries + render > ceiling), no amount of caching fixes the adversarial case (cache-buster params, post-deploy cold isolate, long-tail URLs, crawlers). Move the miss execution itself.

**Architecture** (worker entry owns both):
```ts
// src/worker.ts
import { handle } from '@astrojs/cloudflare/handler'; // or your framework's handler
import { DurableObject } from 'cloudflare:workers';

export class RenderDO extends DurableObject<Env> {
  #inflight = new Map<string, Promise<ArrayBuffer & /* +meta */ any>>();
  override async fetch(req: Request): Promise<Response> {
    // 1. secret gate: reject unless x-md-render-do === env.RENDER_DO_SECRET
    // 2. coalesce: key = req.url; reuse #inflight promise if present
    // 3. render: strip secret header, SET x-md-do-inner: 1, re-invoke handle()
    //    with ctx shim { waitUntil: (p) => this.ctx.waitUntil(p) }
    // 4. buffer body (arrayBuffer) so the promise is reusable by coalesced waiters
  }
}
export default { fetch: (req, env, ctx) => handle(manifest, app, req, env, ctx) };
```

Middleware (edge-cache) flow:
```
inner sentinel present? → render inline (we ARE inside the DO) — NEVER dispatch
cache HIT → serve
MISS → env.RENDER_DO.idFromName(cacheKeyUrl) → stub.fetch(req + secret header)
     → wrap: res = new Response(doRes.body, doRes)  // headers immutable!
     → stamp observability header UNCONDITIONALLY (inner pass already stamped its own)
DO failure → ladder: stale cache → inline render (may 1102, was status quo) → 503
```

**Non-negotiable details** (each one bit during build):
1. **Recursion sentinel** — DO re-invokes the same middleware. Without inner-pass header check BEFORE the dispatch branch: DO dispatches to itself → coalescing-promise deadlock. Check sentinel first. **General case, not just render-dispatch**: ANY DO whose `fetch()`/`alarm()` handler makes an outbound `fetch()` to an app route is at risk if that route's middleware reads back into the same DO instance — a stalled storage write on that instance then errors/discards every other in-flight message to it (output-gate semantics), not just the recursive call. Confirmed instance: `CacheEpochDO.alarm()` prewarm → `edge-cache.ts` → `getEpoch()` → same global DO (`idFromName('catalog')`) — see `md-server-dev` Learned Rules `do-middleware-dispatch-needs-recursion-sentinel | fired:2`. Trace every outbound fetch target's middleware chain before shipping a DO with self-triggered fetches.
2. **`idFromName(cacheKeyUrl incl BUILD_ID)`** = one DO instance per cache key = free *global* request coalescing (cross-isolate, cross-PoP): N concurrent misses on one URL = 1 render. Verified: 5 parallel same-URL cold probes all 200, clustered same wall time.
3. **Secret-gate the DO** — anyone who can reach the namespace can burn quota. Shared secret header from a Worker secret.
4. **Immutable headers** — gotcha 18. Wrap before stamping.
5. **Deploy config patching** — gotcha 20. Adapter regenerates wrangler.json without `migrations`; patch + entry grep gate in deploy script, every deploy.
6. **Failure ladder mandatory** — DO error/quota-exhaustion must degrade to stale-then-inline, not hard-fail.

**Free quota math** (do this before adopting): 100k DO requests/day + 13k GB-s/day. DO only sees cache MISSES — at 95%+ hit ratio, thousands of renders/day vs 100k cap. Abuse-scale unique-URL flood can exhaust quota → ladder degrades to inline → original ceiling problem returns for the rest of the day. Acceptable degradation, but know it.

**Observability**: stamp a render-path header taxonomy (`edge-hit` / `do-render` / `inline-fallback` / `stale-fallback` / `coalesced`) — makes live verification a one-liner curl loop instead of guesswork.

---

## Pages vs Workers: the 2026 answer is always Workers+Assets

Never split into "UI on Pages + backend on Workers" without specific reason.

### Why splitting fails

1. **Cron Workers-only** — Pages has no `scheduled()` export. Need any cron → need Workers.
2. **Same edge, same latency** — no speed advantage splitting.
3. **Same quota** — Pages Functions count against Workers tier.
4. **Framework adapters target Workers** — Astro, Next.js (OpenNext), SvelteKit, Remix all build for Workers+Assets. Pages = legacy fallback.
5. **SSR needs DB layer** — unified model = one query layer. Split = duplicate or HTTP round-trip (slower, CORS).
6. **Sessions stay single-origin** — `SameSite=Lax` cookies work at `{app}.workers.dev`. Cross-subdomain = wider attack surface + CORS.
7. **Bindings unified since 2024** — R2/D1/KV/Hyperdrive/AI/Queues/DO same in both. Historical Pages binding gap gone.

### The ONE legitimate split
Marketing page at apex on Pages + app at `app.example.com` on Workers+Assets. Only if different tech stack / CMS / deploy cadence.

### Migration
Pages project grown crons/queues/DO → use MCP `migrate_pages_to_workers_guide`.

---

## When to pick MCP vs wrangler vs curl

| Situation | Pick |
|---|---|
| One-off D1 query | MCP `d1_database_query` |
| List/get existing resources | MCP `*_list` / `*_get` |
| Deploy Worker | Delivery controller |
| Push secret | wrangler CLI |
| Stream logs | `wrangler tail` |
| Hit endpoint MCP doesn't cover | curl with OAuth token (check scope first) |
| Operation OAuth can't authorize | Ask user for scoped API token (last resort) |
| Verify pricing / limits / product behavior | MCP `search_cloudflare_documentation` |

**Default order:** MCP first → wrangler CLI → curl. Ask user only for `images:*`, `stream:*`, `access:*`, `user:tokens:edit`.

---

## Quick verification

```bash
pnpm exec wrangler whoami
# Deploy dry-run belongs to designated executor/controller receipt.
pnpm exec wrangler r2 bucket list
pnpm exec wrangler d1 list
pnpm exec wrangler kv namespace list

TOKEN=$(grep oauth_token ~/.config/.wrangler/config/default.toml | awk -F'"' '{print $2}')
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCT/<path>"
```

---

## Queues

**Available on Workers Free.** Not Paid-only. Verified 2026-05-28.

| Plan | Ops included | Overage | Retention |
|---|---|---|---|
| Workers Free | 10,000 ops/day | none | 24h (non-configurable) |
| Workers Paid | 1M ops/month | $0.40/M | 4 days default, up to 14 days |

1 op = 1 message written, read, OR deleted (per 64 KB chunk). Typical delivery = 3 ops.
Ref: https://developers.cloudflare.com/queues/platform/pricing/

### Multideal queues (measured live 2026-06-13 via GraphQL)

Convention: one queue per work class + paired DLQ. **Account total 2026-06-13 = 10,203 ops/24h (102% of free tier)** — 99.9% of it from the settlements pair below (UNWIRED consumer, see incident note above). Every other queue measured 0 ops/24h.

| Queue | ops/24h | Work class |
|---|---|---|
| `multideal-settlements-preview` | **6,800** | Stripe vendor payouts — **consumer never routed in `queue()` dispatcher → `default: throw` → whole-batch retry ×5 → DLQ; fix pending** |
| `multideal-settlements-dlq-preview` | **3,400** | DLQ — also unrouted (hit same `default: throw`), so every dead-lettered msg re-failed |
| `multideal-outbox-preview` | 3 | Transactional side-effects (outbox pattern) |
| `multideal-llm-jobs-preview` | 0 | Async LLM API calls |
| `multideal-translation-jobs-preview` | 0 | Async translation work |
| `*-dlq-preview` (outbox/llm/translation) | 0 | DLQs for the above |

Wrangler: `wrangler queues list` / `wrangler queues create <name>`. Binding declared per env in `wrangler.toml`.

### Cost model — the only three levers (verified against pricing + limits docs)

**Operations are billed PER MESSAGE, not per batch.** A message normally costs 3 ops over its life: 1 write (producer `send`), 1 read (consumer delivery), 1 delete (ack). `sendBatch()` and a larger `max_batch_size` change how many messages a single Worker invocation handles — they do **NOT** reduce operations. Batching is a CPU / invocation-count lever, never an ops lever.

Free tier = **10,000 ops/day**, no overage (messages rejected once exhausted), 24h retention, non-configurable. At 3 ops/message that is **≈3,300 messages/day** total across all queues on the account. Each redelivery (retry) adds **+1 read op per redelivered message**, so a high `max_retries` on a flaky consumer multiplies cost.

The only ways to cut operations:
1. **Send fewer messages** — coalesce N events into 1 (see below). This is the dominant lever.
2. **Keep messages < 64 KB** — billing is per 64 KB chunk; a 128 KB message bills 2 ops per operation.
3. **Retry less** — fix consumer idempotency/failure modes; prefer per-message `msg.retry()` over `batch.retryAll()` so only failures redeliver.

Changing `max_batch_size`/`max_batch_timeout` to save ops is a **no-op** — do not recommend it for free-tier relief. (Those knobs are real, but they only affect invocation count, CPU per invocation, and subrequest fan-out — tune them to the workload, not to the bill.)

### Measure the real offender FIRST — never infer from call-site density

Before optimizing, pull actual billable ops per queue. The GraphQL Analytics dataset is `queueMessageOperationsAdaptiveGroups` (Account introspection is disabled, but the dataset and its fields below are confirmed live). `actionType` ∈ {WriteMessage, ReadMessage, DeleteMessage}; `outcome` reveals failures (`dlq`, `fail`). Reads >1× write count = retries; a hot DLQ = a failing consumer.

```bash
# Token auto-refreshes if you run any `wrangler` cmd from a project that has it installed first.
A=<account_id>; T=$(grep oauth_token ~/.config/.wrangler/config/default.toml | head -1 | sed 's/.*= *"//;s/".*//')
# id->name: GET /accounts/$A/queues  (REST, returns queue_id + queue_name)
# ops 24h: POST https://api.cloudflare.com/client/v4/graphql
#   { viewer { accounts(filter:{accountTag:$A}) {
#       queueMessageOperationsAdaptiveGroups(limit:5000, filter:{datetime_geq:$since}) {
#         sum { billableOperations } dimensions { queueId actionType outcome } } } } }
```
curl is hook-redirected here — run the fetch inside `exec` (Node `fetch`) so the raw body stays out of context. A healthy message = 3 ops (W+R+del). 6 reads/message = `max_retries=5` firing on every message = a broken consumer, not a volume problem. **The 2026-06-13 incident was exactly this:** `multideal-settlements-preview` + its DLQ = 99.9% of account ops (10.2k/24h, the 90% email). Root cause was *plumbing, not logic*: the consumer (`apps/web-do/src/index.ts` `queue()`) routed only `outbox`+`llm-jobs`; settlements + its DLQ fell through to `default: throw new Error("Unknown queue")`, so the WHOLE batch failed and CF retried it ×5 then dead-lettered — and the DLQ, also unrouted, re-failed every message. The consumer's own funds-pending `retry()` logic never ran (unreachable). **Lesson: read the dispatcher before naming the mechanism** — I first wrote this up as a retry-on-business-state bug from reading the consumer in isolation, which was wrong. Separately, inferred ranking (audit-log density) had pointed at the wrong project entirely; every other queue was 0 ops.

### When to USE a queue

- **Durable cross-Worker handoff** that must survive producer death — work you cannot lose and cannot finish inline.
- **Retriable burst work** — expensive/rate-limited side effects (LLM calls, email, third-party writes) you want smoothed and auto-retried with a DLQ backstop.
- **Decoupling a slow consumer** from a latency-sensitive producer.

### When NOT to use a queue

- **Fire-and-forget side effects within one request** (cache warm, analytics ping, non-critical write) → use `ctx.waitUntil(promise)`. Zero queue ops, runs after response. Cost of moving these to a queue is pure waste.
- **Audit / event logs** (per-mutation append) → this is the classic free-tier killer: one mutation = one message = 3 ops, so a busy app blows 10k/day fast. Write to an append sink (D1 outbox row, R2 NDJSON object, or a Durable Object that coalesces) instead — and coalesce.
- **Polling on a frequent cron** that enqueues every run → a `* * * * *` cron is 1,440 invocations/day; if each enqueues it drains the tier. Prefer event/webhook push or a **Durable Object alarm**.
- **Anything you can coalesce** — see below.

### Retry hygiene (general best-practice — NOT what caused 2026-06-13)

> The 2026-06-13 incident was an *unwired* consumer (`default: throw`), not bad retry logic. These rules still hold as general guidance and the same consumer would have hit the first one once wired with no Stripe credential — but don't cite the incident as proof of them.

- **Never `retry()` an expected or permanent error.** A missing credential/config is *permanent* — acking + recording it (the house `strict=false` "warn+ack" convention) is correct; retrying it just burns ops and never succeeds. Likewise distinguish Distinguish transient infra failure (DB blip, 5xx, rate-limit → retry) from "not ready yet" (insufficient funds, awaiting upstream, hold). Retrying a not-ready state burns `max_retries`+1 reads per message and then dead-letters work that was never going to succeed on a fixed schedule. Correct: `ack()`, persist a `held`/`pending` status, and let a **cron or DO `alarm()` backstop** re-sweep when the precondition is met.
- **DLQ consumers must drain (`ack`), never throw.** A DLQ exists for inspection/replay; a throwing DLQ handler re-reads and re-fails every dead-lettered message, doubling the waste of the bug that filled it.
- **Keep `max_retries` low (1–2) on consumers that touch money or third parties** — high retry counts multiply ops and re-run side effects. Idempotency keys (Stripe) make this safe but don't make it free.

### Event coalescing (the real fix, sink-agnostic)

Collapse N events into 1 before they hit any metered sink. Buffer in a Durable Object (in-memory + `alarm()` flush on timer/threshold) and emit one rolled-up message/row per window. Turns "1 op per event" into "1 op per window" — the same principle whether the sink is a queue, D1, or R2. This beats "just move it off queues," because the next sink has its own ceiling (see D1).

### D1 is NOT an unlimited escape hatch

D1 free tier = **5M rows read/day, 100,000 rows written/day, 5 GB storage**, billed **per row** (a batched `INSERT` of 10 rows = 10 writes). So "drop the audit queue, write to D1" only buys ~10× headroom over the 10k-ops queue ceiling, on a bounded, account-shared budget — it does **not** remove the constraint. Coalesce first, then pick a sink. (Pricing: https://developers.cloudflare.com/d1/platform/pricing/ , limits: https://developers.cloudflare.com/d1/platform/limits/ )

### Free-tier alternatives to a queue

| Need | Use instead of a queue |
|---|---|
| Fire-and-forget after response | `ctx.waitUntil()` (no durability) |
| Scheduled/delayed single task | Durable Object `alarm()` |
| Multi-step durable pipeline | Cloudflare Workflows |
| Append-only audit/log | R2 NDJSON object or D1 outbox row (coalesced) |
| Debounce/dedupe bursts | DO in-memory buffer + alarm flush |

slopgate (stack pack `cloudflare`) lints `batch.retryAll()` as a **low/advisory** signal — it redelivers and re-bills the whole batch and re-runs already-succeeded side effects; legitimate only for whole-batch fate-sharing (coarse single-sweep handlers). Per-message handlers should `ack()` successes and `retry()` only failures.

### `wrangler secret put` success ≠ correct — verify with a runtime call (2026-06-13)

`printf '%s' "$KEY" | wrangler secret put NAME --name WORKER` printed `✨ Success! Uploaded secret` while having uploaded a **corrupted** key — wrangler's first-run interactive prompt (`Cloudflare agent skills are available…`) and/or `npx` consumed part of the piped stdin. The CLI success line is a lie; secrets are write-only so you cannot read the value back to check. The only truth was the worker's runtime `Invalid API Key`. Rules:
- After piping a secret, **verify by exercising the code path** (cron tick, health route, or a direct provider call with the same source value) — never trust the CLI success message.
- Pre-empt the stdin-eater: run a throwaway `wrangler whoami` first (clears the first-run prompt), or use `--install-skills`/non-interactive flags, before the piped `secret put`.
- Validate the key value independently before upload (`curl -u "$KEY:" https://api.stripe.com/v1/balance` → expect 200) so a runtime 401 unambiguously means *upload corruption*, not a bad key.

### Monitoring gap — don't rely on Cloudflare's 90%-of-tier email

The 2026-06-13 incident was caught only by CF's automated "90% of Queues free tier" email; 229 unpayable rows had sat silently for ~6 weeks. There is no proactive alarm on queue op-rate or on DB rows stuck in a non-terminal status. Until one exists, the daily-ish manual check is the GraphQL measurement recipe above (project ops/day vs 10k) plus a status-distribution query on the work table (`GROUP BY status` — watch for growing `held`/`enqueued` with climbing retry/attempt counters). A real fix is a scheduled worker that runs both and pages on threshold.

---

**Last verified:** 2026-04-12 against wrangler 3.114.17 + Cloudflare MCP. Queues section added 2026-05-28. DO render offload section + gotchas 18-21 added 2026-06-10 (proven live: Multideal RenderDO, Astro 6 + @astrojs/cloudflare 13.5, Workers Free). Queue cost-model / when-to-use / coalescing / D1-not-unlimited / alternatives added 2026-06-13 (verified against CF Queues + D1 pricing & limits docs). Measurement recipe + retry-hygiene + live multideal op counts added 2026-06-13 (measured via GraphQL queueMessageOperationsAdaptiveGroups during the settlements-consumer incident). Secret-put-corruption + monitoring-gap gotchas added 2026-06-13 (proven live: silent corrupt upload caught only by worker runtime `Invalid API Key`, re-put after clearing wrangler first-run prompt; post-fix measured 1,656 ops/day = 16.6% tier, R/W 1.20).
