# Sync to Cloudflare

Cloudflare gives a Worker its secrets before your code runs — there is no
earlier point at which seekrit could inject them. Three destinations live behind
one platform here, and they behave differently enough to be worth choosing
deliberately.

| | Workers | Pages | Secrets Store |
| --- | --- | --- | --- |
| **What seekrit writes** | A Worker's `secret_text` bindings — the slot `wrangler secret put` writes | A project's environment variables (`secret_text`), per deployment config | Account-level secrets that Workers bind by name |
| **Addressed by** | Worker script name | Pages project name + deployment configs | Store id + the scopes new secrets are created with |
| **Token permission** | Workers Scripts: Edit | Cloudflare Pages: Edit | Secrets Store: Edit |
| **Takes effect** | Immediately, on the Worker's current version | Next deployment (Pages binds at build time) | Next Worker deploy — a Worker needs a `secrets_store_secrets` binding to read one |

New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the
decryption grant, name mapping, deletions, and failure handling are the same on
every destination.

## 1. Create a scoped API token

All three destinations share one connection shape: an API token plus the
**account ID** it belongs to (32 lowercase hexadecimal characters, in the
sidebar of any Cloudflare dashboard page). What differs is the token permission
each needs.

In Cloudflare, go to **My Profile → API Tokens → Create Token**, use *Create
Custom Token*, and grant the one account-level permission from the table above —
not the global API key, which carries everything.

> **Note:** **A connection is per product, because the token permissions are.** Syncing to both Workers and Pages means two connections, each with its own scoped token. That is a feature rather than friction: a leaked Pages token cannot rewrite a Worker's secrets.

## 2. Add the connection

```bash
printf '%s' "$CLOUDFLARE_API_TOKEN" | seekrit sync connect \
  --name acme-cloudflare --provider cloudflare-workers \
  --account-id 0123456789abcdef0123456789abcdef
```

`--account-id` is required for all three Cloudflare providers, and seekrit checks
its shape before storing it — the alternative is a bare 400 from Cloudflare hours
later, inside a run nobody is watching. It cannot catch a *zone* id pasted by
mistake, which has the same shape; only the API can tell those apart, so verify a
destination before you bind to it.

## 3. Bind an environment

### Workers

Name the script. A Wrangler environment is its own Worker, so deploying `my-api`
with `--env staging` creates `my-api-staging` — name that instead. Secrets land
on the Worker's current version immediately, with no redeploy:

```bash
seekrit sync verify acme-cloudflare --provider cloudflare-workers --script my-api

seekrit sync enable --connection acme-cloudflare \
  --provider cloudflare-workers --script my-api \
  --app storefront --env production --acknowledge-decryption
```

Verify lists the script's secret **names** — never values — which is the cheapest
call that proves all three things a run needs: the token is valid, it is scoped
to this account, and the Worker is deployed there.

### Pages

Name the project and the deployment configs (`production`, `preview`), reusing
`--target`:

```bash
seekrit sync enable --connection acme-pages \
  --provider cloudflare-pages --project my-site --target production,preview \
  --app storefront --env production --acknowledge-decryption
```

> **Note:** Pages binds environment variables at **build time**. A pushed value reaches the running site on its next deployment — seekrit cannot force one, because a Pages deploy is a build, not a config reload. Workers are the opposite: values apply to the running Worker at once.

### Secrets Store

Name the store (`wrangler secrets-store store list`, or the dashboard) and the
scopes new secrets are created with:

```bash
seekrit sync enable --connection acme-store \
  --provider cloudflare-secrets-store \
  --store-id 0123456789abcdef0123456789abcdef --scopes workers \
  --app storefront --env production --acknowledge-decryption
```

`--scopes` takes a comma-separated list from `workers`, `ai_gateway`, `dex`,
`access`, `containers`, and `websearch`, and defaults to `workers`. Cloudflare
requires at least one at creation and cannot infer them, which is why a binding
states them.

Store secrets are account-level: a Worker still needs a `secrets_store_secrets`
binding in its Wrangler config to read one, and adding that binding is a deploy.

## How a push behaves

**Workers** batches. `PATCH …/secrets-bulk` carries up to 100 upserts and
deletes per request, so a 500-secret environment is five requests rather than
five hundred. A `null` value deletes a name and an omitted name is left alone,
so a push never disturbs a binding it doesn't own. The cost is granularity: if
Cloudflare rejects a batch, every name in it is reported as failed, because that
is all the API tells us. Re-pushing a value that did in fact land is harmless.

**Pages** is a single `PATCH` of the project's `deployment_configs`. There is no
partial landing to report — it either applies or it does not. seekrit reads the
project first so removals are only sent where the key actually exists; Pages
redacts `secret_text` values on read, so that listing returns names only.

**Secrets Store** addresses every write by secret **id**, and seekrit only knows
names, so a run pages through the store's metadata (100 per page) to map them.
Creates are batched; updates and deletes are one request each. An update sends
the **value alone** — `scopes` and `comment` belong to the destination once a
secret exists, so widening a scope in the Cloudflare dashboard survives the next
sync. Secrets seekrit creates carry a comment marking them as managed.

All three share a per-run budget of 400 requests, sized against the Worker
subrequest cap. A run that hits it reports more work and the engine re-runs
immediately.

> **Note:** **Cloudflare does not always disagree loudly.** A handful of its endpoints answer `200` with `success: false` and a populated `errors` array. seekrit treats that as a failure rather than a landed push — otherwise a run would mark secrets as delivered that never left the building.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `403` on every name | The token is missing that destination's one permission, or was issued for a different account | Check the permission in the table above, and that the account id matches the token's account |
| `404` on the Worker, project, or store | Renamed or deleted | Destinations are addressed by name, so a rename breaks the binding — update it. For a Wrangler environment, name the derived Worker (`my-api-staging`) |
| `400` right after adding a connection | A **zone** id was pasted instead of an account id | Both are 32 hex characters, so only the API tells them apart. Take the account id from the dashboard sidebar |
| Secrets pushed, Pages site unchanged | Pages binds at build time | Redeploy the project. seekrit does not start Pages builds |
| Store secret exists, Worker can't read it | The Worker has no `secrets_store_secrets` binding for it | Add the binding in `wrangler.jsonc` and deploy |
| A whole batch of names failed at once | Workers and Secrets Store write in batches | The API reports per batch, not per name. Fix the cause and re-run; the retry is idempotent |

## See also

- [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions
- [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag
