---
name: cloudflareops
description: Operational reference for Cloudflare via MCP, wrangler CLI, and the REST API. Use when deploying Workers, managing R2/KV/D1/Hyperdrive/Queues, pushing secrets, setting up crons, creating API tokens, or debugging "which tool can do X". Tells you exactly what each surface CAN and CANNOT do so you don't waste a turn researching it. Empirically verified — all claims were probed on a real account before being written here.
---

# Cloudflare Ops

Quick-reference for anything you do against Cloudflare. **Read this before you ask the user to "run a dashboard step"** — most things are automatable, and this doc tells you which.

## TL;DR decision matrix

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

## Authentication state

**Wrangler login uses OAuth**, not an API token. The OAuth token lives at `~/.config/.wrangler/config/default.toml` and rotates (`expiration_time` field). Its scopes are limited and cannot be extended without `wrangler logout && wrangler login`.

### Scopes the wrangler 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 the OAuth token does NOT carry
- **`images:*`** — Cloudflare Images, Image Transformations
- **`stream:*`** — Cloudflare Stream
- **`access:*`** / **`zero_trust:*`** — Access / Zero Trust
- **`email:*`** — Email Routing
- **`user:tokens:edit`** — cannot create API tokens programmatically
- **`dns:edit`** / full `zone:edit` — cannot edit DNS records, only read
- **`billing:*`** — cannot subscribe/unsubscribe products
- **`logs:*`** — cannot access Logpush / analytics

## What each surface can do — verified against a real account

### The Cloudflare MCP (server reports connected)

Exposed tools (26 as of April 2026):
```
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
```

**MCP covers:** Accounts, Workers (list/get only — NO deploy), R2, KV, D1, Hyperdrive, Pages-migration guide, docs search.

**MCP does NOT cover:**
- Deploying Workers (use `wrangler deploy`)
- Secrets (`wrangler secret put/bulk`)
- Cron triggers (part of `wrangler deploy`)
- Tailing logs (`wrangler tail`)
- Queues, Vectorize, AI Models, Pages, Stream, Email Routing, Access, DNS, Images, Image Transformations, Pub/Sub, Dispatch, Workflows, mTLS, AI Search, Workers AI training, Pipelines, Containers, Cloudchamber, Secrets Store

### wrangler CLI (v3.114 verified; v4 similar)

Top-level commands:
```
init / dev / deploy / deployments / rollback / versions / triggers / delete / tail
secret (put|delete|list|bulk)    types
kv                  queues              r2              d1
vectorize           hyperdrive          cert            pages
mtls-certificate    pubsub              dispatch-namespace
ai                  workflows
login               logout              whoami
```

**Wrangler covers end-to-end:** Workers deploy, secrets, cron triggers (via `wrangler.toml` + `wrangler deploy`), logs, rollbacks, R2 objects/buckets, D1 execute, KV keys, Queues, Vectorize, Hyperdrive, Pages, Workflows, AI, mTLS, Dispatch, Pub/Sub.

**Wrangler CLI does NOT natively cover:**
- **Cloudflare Images** (no `wrangler images` command)
- **Stream** (no `wrangler stream` command)
- **DNS records** (`wrangler` has no `dns` subcommand — use the REST API with a zone-scoped API token)
- **Access / Zero Trust** (use REST API)
- **Email Routing** (REST API only)
- **Creating API tokens** (must be done in dashboard or via an existing parent API token)
- **Enabling / disabling products** (subscribing to Images, Stream, Workers Paid, etc. requires the dashboard billing flow)

### REST API with wrangler OAuth token (empirically probed)

Token value and account id:
```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 | HTTP | Notes |
|---|---|---|
| `GET /accounts/:a/workers/scripts` | ✅ 200 | list/manage workers |
| `GET /accounts/:a/workers/subdomain` | ✅ 200 | `*.workers.dev` subdomain |
| `GET /accounts/:a/d1/database` | ✅ 200 | D1 full access |
| `GET /accounts/:a/storage/kv/namespaces` | ✅ 200 | KV full access |
| `GET /accounts/:a/r2/buckets` | ✅ 200 | R2 full access |
| `GET /accounts/:a/queues` | ✅ 200 | Queues full access |
| `GET /accounts/:a/ai/models/search` | ✅ 200 | Workers AI model search |
| `GET /accounts/:a/pages/projects` | ✅ 200 | Pages |
| `GET /zones` | ✅ 200 | zone list (read only) |
| `GET /accounts/:a/images/v1` | ❌ 403 `code:10000` | **NO `images:*` scope** |
| `GET /accounts/:a/images/v1/stats` | ❌ 403 | same |
| `GET /accounts/:a/stream` | ❌ 403 `code:10000` | **NO `stream:*` scope** |
| `GET /accounts/:a/access/organizations` | ❌ 403 `code:10000` | **NO `access:*` scope** |
| `GET /accounts/:a/email/routing` | ❌ 404 `code:10001` | endpoint or config missing |
| `GET /user/tokens` | ❌ 403 `code:9109` | **cannot create API tokens with OAuth** |
| `GET /user/tokens/verify` | ❌ 401 `code:1000` | OAuth bearer verify is different |

So anything on 🚫 is **out of reach with the current wrangler OAuth token** unless you either:
1. `wrangler logout && wrangler login` with a browser flow that requests additional scopes (only works if Cloudflare exposes the scope to OAuth)
2. Have the user create a **scoped API token** in the dashboard once (Profile → API Tokens → Create Token → Custom) and share it, then use `CLOUDFLARE_API_TOKEN=xxx` envar with curl/wrangler
3. Use a different authenticated session that has the scope

## What ALWAYS requires a human clicking in the dashboard

These are not bugs — they're intentional Cloudflare product gates:

- **Subscribing to paid products** (Cloudflare Images $5/mo, Stream pay-as-you-go, Workers Paid $5/mo, Access, etc.) — the billing flow requires a credit card and legal consent.
- **Enabling Image Transformations on a zone** — one click under `Speed → Optimization → Image Transformations` per zone. Free on all plans but disabled by default.
- **Creating a new API token** with custom scope — Profile → API Tokens → Create Token. OAuth can't create tokens.
- **Connecting a custom domain** to a Worker — `wrangler` can create the route, but adding the apex `A`/`CNAME` record still requires zone setup if it's a new zone.
- **Accepting Workers Paid for higher limits** — billing flow.
- **Registering a new domain through Cloudflare Registrar** — checkout flow.

Everything else — deploying, secrets, cron triggers, storage, DB, queues, AI, Hyperdrive, Pages, KV, D1, R2 — is fully automatable without a browser.

## Idiomatic recipes

### Deploy a Worker (preview → prod)
```bash
# Build
pnpm build

# Preview (non-prod) — uses [env.preview] in wrangler.toml
pnpm exec wrangler deploy --env preview

# Prod (usually gated by GH Actions on a tag)
pnpm exec wrangler deploy --env production
```

### Push every secret from .env (no looping)
```bash
# Build a JSON object and pipe to `wrangler secret bulk`
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
```

Or the verbose way (one secret at a time):
```bash
printf '%s' "$VALUE" | pnpm exec wrangler secret put KEY --env preview
```

### Create R2 bucket + bind it
```bash
pnpm exec wrangler r2 bucket create my-bucket
# Then add to wrangler.toml:
# [[r2_buckets]]
# binding = "R2_BUCKET"
# bucket_name = "my-bucket"
# ...and `wrangler deploy` picks it up.
```

### Cron triggers
Define in `wrangler.toml`:
```toml
[triggers]
crons = ["* * * * *", "0 0 1 * *"]
```
Deploy the Worker and the triggers auto-register. Manual trigger:
```bash
pnpm exec wrangler cron trigger --cron "* * * * *"
```

### Worker needs to serve static assets (the "Workers with Static Assets" model)
```toml
main = "./dist/_worker.js/index.js"

[assets]
directory = "./dist"
binding = "ASSETS"
```
**GOTCHA**: wrangler refuses to upload `_worker.js/` as a public asset — create `dist/.assetsignore` (or put it in `public/.assetsignore` so Astro copies it):
```
_worker.js
_routes.json
```

### Env inheritance (THIS ONE BITES)
**Top-level `[[r2_buckets]]`, `[triggers]`, `[assets]`, `compatibility_date`, etc. are NOT inherited by `[env.preview]` or `[env.production]`.** You MUST re-declare them per environment or wrangler will deploy without them and silently break runtime bindings.

Minimum preview 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"
```

### Pages vs Workers — which should you use?
**In 2026: pick Workers with Static Assets.** Pages is being folded into Workers and the unified model is the recommended path. The Cloudflare MCP even has a `migrate_pages_to_workers_guide` tool as a first-class operation.

Pages has **hard blockers** Workers doesn't:
- ❌ **No cron / scheduled handlers** (Pages Functions only fire on HTTP)
- ❌ **No `scheduled(event, env, ctx)` export**
- ❌ **No Durable Objects binding directly** (must go through a separate Worker)
- ❌ **No binding reuse across multiple sites** (each Pages project is its own silo)

If you need any cron, switch to Workers + Assets now. Astro's Cloudflare adapter already targets this model.

## Gotchas catalogue

1. **`${{ ... }}` in GitHub Actions `run:` blocks** — `gh` workflow parser flags this as a potential command injection. Use env blocks: `env: KEY: ${{ secrets.FOO }}` then `run: echo "$KEY"`. Needed in `.github/workflows/deploy-*.yml`.
2. **Wrangler 3 → 4 upgrade** — wrangler 4.x uses a different `assets` config syntax and some env flags changed. If you see "Unknown argument", check the migration guide: `pnpm dlx wrangler@4 versions`.
3. **Workers KV "SESSION" binding warning** from `@astrojs/cloudflare`** — the adapter tries to wire a KV binding called `SESSION` for Astro's built-in sessions feature. If you store sessions elsewhere (Neon, etc.), set `sessionKVBindingName: undefined` in the adapter options.
4. **`optimizeDeps` + drizzle in dev mode** — Vite's module runner can't see `inArray`, `eq`, etc. from `drizzle-orm`. Fix in `astro.config.mjs`:
   ```js
   vite: {
     ssr: { noExternal: ['drizzle-orm'] },
     optimizeDeps: { exclude: ['drizzle-orm', '@neondatabase/serverless'] },
   }
   ```
5. **Cron triggers on `wrangler deploy`** land on the **top-level** script only. For environments, redeclare under `[env.<name>.triggers]`.
6. **`wrangler deploy` exposing `_worker.js`** — see `.assetsignore` recipe above.
7. **R2 bucket region** — Cloudflare automatically picks a region close to your account; for EU-only data you need a paid plan and `--jurisdiction eu` flag.
8. **`wrangler secret put` is stdin-only** when scripting. Pipe via `printf '%s' "$VALUE" | wrangler secret put KEY` — never `echo` (adds a trailing newline) and never put the value on the command line (leaks into `ps aux`).
9. **OAuth token expires after ~1 hour** — `expiration_time` in the wrangler config. If a long-running script fails with 401, it rotated; re-run `wrangler whoami` to refresh.
10. **`workers.dev` subdomains are `{worker-name}.{random-slug}.workers.dev`** — the full URL is only printed by `wrangler deploy` at the end. Capture from the deploy output if you need it programmatically.

## Cloudflare Images vs Image Transformations — know the difference

These are TWO DIFFERENT products and the distinction matters:

### Cloudflare Images (the storage + delivery product)
- **Paid: $5/mo base + $1 per 100k deliveries + $5 per 100k stored.**
- Stores image originals in Cloudflare's own storage, serves variants from `imagedelivery.net/{account-hash}/{image-id}/{variant}`.
- Requires dashboard subscription, an account-scoped API token (`Account → Cloudflare Images → Edit`), and the account hash.
- **Cannot be provisioned by wrangler OAuth or the MCP** — the scope isn't in OAuth and the MCP has no Images tools. A human must click in the dashboard once to subscribe + create the token.

### Image Transformations (the on-the-fly transform product)
- **FREE on the Workers free tier.** No subscription.
- Enable once per account at `Dashboard → Images → Transformations → Enable`. One-click, free.
- Does NOT store images — it transforms on-the-fly from any source you fetch (R2, external URL, another Worker).
- **Works from `workers.dev` subdomains — no custom domain required.** This is the key thing people miss.

### Three ways to USE Image Transformations from a Worker

**1. The `IMAGES` binding (recommended — newest, cleanest, most typed)**
```toml
# wrangler.toml
[images]
binding = "IMAGES"

# Repeat per-env — env blocks don't inherit:
[env.preview.images]
binding = "IMAGES"

[env.production.images]
binding = "IMAGES"
```
```ts
// In the Worker
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. The `cf.image` fetch option (older, still works)**
```ts
const transformed = await fetch(sourceUrl, {
  cf: { image: { width: 400, format: 'avif' } }
});
```

**3. The `/cdn-cgi/image/<opts>/<url>` URL pattern**
**This is the ONLY variant that requires a zone (custom domain).** Avoid unless you have a custom domain and want URL-based transforms. The `IMAGES` binding covers every use case.

### Hetzi-style image proxy recipe
Create `src/pages/api/img/[...path].ts` that accepts `/api/img/<variant>/<format>/<r2-key>`, fetches the original from R2, transforms with the `IMAGES` binding, and returns with immutable cache headers. Full code example lives at `src/pages/api/img/[...path].ts` in the Hetzi repo. Key design points:
- URL is **content-addressed by R2 key** → safe to cache forever (`Cache-Control: public, max-age=31536000, immutable`)
- Path-traversal guard on the R2 key (`..`, backslash)
- Fallback to the R2 original on transformation error
- Variant dimensions live in both the proxy and the `buildVariantUrl` helper — keep them in sync

### Zero-cost fallback (if Image Transformations isn't enabled yet)
Serve R2 originals unchanged. The `<picture>` element with `width`/`height` and the `sizes` attribute still gives responsive layout; you just lose AVIF/WebP variants and per-breakpoint resizing. `buildVariantUrl` returns the original `src` passthrough in this mode.

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

**Do NOT split a Cloudflare deployment into "UI on Pages + backend on Workers" unless you have a specific reason that isn't on this list.**

### Why splitting fails to pay off

1. **Cron triggers are Workers-only.** Pages Functions have no `scheduled(event, env, ctx)` export. If your app needs any cron, you still need a Workers deploy — splitting adds a second project without removing the first.
2. **Same edge, same latency.** Pages sits on the same Cloudflare edge as Workers. There is no faster tier. "UI on Pages" is not faster than "Worker serving static assets from the edge CDN" — it's the same bytes from the same cache.
3. **Same quota.** Workers free tier is 100k req/day. Pages Functions count against the same paid Workers tier. There is no free-Pages-separate-from-Workers billing split that saves money.
4. **Framework adapters target Workers.** Astro, Next.js (via OpenNext), SvelteKit, Remix — all their Cloudflare adapters build for Workers+Assets as of 2026. Pages deployment is legacy fallback. Splitting means fighting the framework.
5. **SSR page queries need your DB.** Every SSR page that reads from Neon/D1 needs the query layer. In the unified model, `src/server/db/queries/**` is called from both SSR and API routes, one copy. Splitting means either duplicating the query layer OR forcing Pages Functions to call your own Workers API over HTTP (slower, introduces CORS, breaks auth cookies).
6. **Sessions stay single-origin.** HttpOnly `SameSite=Lax` cookies work without fuss when everything is `{app}.workers.dev`. Splitting to `app.example.com` + `api.example.com` means `Domain=.example.com` — bigger cookie attack surface, CORS preflights, magic-link flow has to cross subdomains.
7. **Bindings unified.** Since 2024, Pages Functions and Workers share the same binding set (R2, D1, KV, Hyperdrive, Images, AI, Queues, DO). The historical Pages-binding-gap is gone.

### The ONE legitimate split

Separate **marketing landing page** at the apex domain on Pages (or another static host), and the **app** at `app.example.com` on Workers+Assets. This is a different-project decision, not "split the existing app into two projects" — it only makes sense if marketing has a totally different tech stack, CMS, and deploy cadence from the product team.

### Migration reminder
If you're starting from a Pages project that has grown crons/queues/DO needs, the Cloudflare MCP has `migrate_pages_to_workers_guide` — use it.

## When to pick MCP vs wrangler vs curl

| Situation | Pick |
|---|---|
| Need to run a one-off D1 query from the agent | **MCP `d1_database_query`** |
| Need to list / get info about existing resources | **MCP `*_list` / `*_get`** (no auth overhead, returns structured JSON) |
| Need to deploy the Worker | **wrangler CLI** (MCP can't do this) |
| Need to push a secret | **wrangler CLI** (MCP can't do this) |
| Need to stream logs during a debug session | **wrangler tail** |
| Need to hit an endpoint the MCP doesn't cover | **curl with OAuth token** — check scope first |
| Need an operation OAuth can't authorize | **ask user for a scoped API token** (last resort) |
| Need to verify pricing / limits / obscure product behavior | **MCP `search_cloudflare_documentation`** (returns the authoritative CF docs) |

**Default order when unsure**: try the MCP first, fall back to wrangler CLI, fall back to curl. Only ask the user as a last resort and only for the `images:*`, `stream:*`, `access:*`, `user:tokens:edit` scopes.

## Quick verification you can run anytime

```bash
# 1. Check token / auth state
pnpm exec wrangler whoami

# 2. See current deployable bindings
pnpm exec wrangler deploy --dry-run --outdir=dist

# 3. List your resources
pnpm exec wrangler r2 bucket list
pnpm exec wrangler d1 list
pnpm exec wrangler kv namespace list

# 4. Probe a specific API endpoint
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>"
```

---

**Last verified:** 2026-04-12 against wrangler 3.114.17 + Cloudflare MCP server state on a free-tier account with Workers Paid off. If something doesn't match reality, re-run the probes and update this file.
