---
name: cloudflareops
description: Use for any Cloudflare operation — deploying Workers, wrangler commands, secrets, R2/D1/KV/Hyperdrive, cron triggers, Queues, Pages-vs-Workers decisions, Cloudflare Images, log tailing, or capacity/latency problems on the bundled plan. Picks MCP vs wrangler vs REST API before falling back to dashboard clicks. Triggers on "deploy worker", "wrangler", "cloudflare", "R2 bucket", "D1", "KV namespace", "worker secret", "cron trigger", "queue costs".
---

# Cloudflare Ops

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

## TL;DR decision matrix

| You want to... | Use this |
|---|---|
| Deploy a Worker | `wrangler deploy [--env <env>]` |
| 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
```bash
pnpm build
pnpm exec wrangler deploy --env preview     # non-prod
pnpm exec wrangler deploy --env production  # prod (usually CI)
```

### 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 | wrangler CLI |
| 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
pnpm exec wrangler deploy --dry-run --outdir=dist
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 `ctx_execute` (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).
