# Environment variables

> Every backend environment variable and its default: `ADMINS`, `PUBLIC_BASE_URL`, the `CF_AI_GATEWAY*` family and its required account/token pair, `CF_ACCESS_AUD`/`CF_ACCESS_ISS`, `AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, `ENABLE_CLOUDFLARE_LIMITS`, `DAILY_LLM_CALL_LIMIT`, `MINIMUM_CLOUDFLARE_BALANCE`, plus `VITE_FRONTEND_ERROR_REPORTING`, `MCP_PORTAL_URL`, and `MCP_PORTAL_TRUST_ANNOTATIONS`.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `packages/workshop-backend/src/env.d.ts`
- `docs/public-server.md`
- `docs/ai-gateway-billing.md`
- `packages/workshop-backend/src/auth/config.ts`
- `packages/gatekeeper-mcp-portal/README.md`
- `run-dev-server.js`

---

---
title: "Environment variables"
description: "Every backend environment variable and its default: `ADMINS`, `PUBLIC_BASE_URL`, the `CF_AI_GATEWAY*` family and its required account/token pair, `CF_ACCESS_AUD`/`CF_ACCESS_ISS`, `AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, `ENABLE_CLOUDFLARE_LIMITS`, `DAILY_LLM_CALL_LIMIT`, `MINIMUM_CLOUDFLARE_BALANCE`, plus `VITE_FRONTEND_ERROR_REPORTING`, `MCP_PORTAL_URL`, and `MCP_PORTAL_TRUST_ANNOTATIONS`."
---

Backend environment variables are declared in `packages/workshop-backend/src/env.d.ts`, which augments the auto-generated `Cloudflare.Env` interface (`worker-configuration.d.ts` is produced by `wrangler types` and is not edited by hand). Nearly every variable is optional and every optional feature is off by default: a deployment with none of them set runs username/password auth with unlimited AI usage, which is the self-hosting posture. Gatekeeper service bindings (`GATEKEEPER_*`) are deliberately absent from that declaration file — core discovers them generically by scanning `env` for the `GATEKEEPER_` prefix (`buildGatekeeperVendorMap`), so no gatekeeper is named in the backend's type surface.

Values reach the Worker either through `wrangler.jsonc` vars, deployed secrets, or — in local development — a gitignored root `.dev.vars` file of `KEY=VALUE` lines that `run-dev-server.js` loads into `process.env` before launching Wrangler. Existing shell values take precedence over `.dev.vars`; surrounding single or double quotes are stripped and `#` comment lines are skipped.

## Variable inventory

| Variable | Type | Default | Purpose |
| --- | --- | --- | --- |
| `ADMINS` | `string[]` | unset | Deployment-wide admin usernames. |
| `PUBLIC_BASE_URL` | `string` | unset | Public base URL of the deployment; the base for every gatekeeper OAuth redirect URI. |
| `CF_AI_GATEWAY` | `string` | unset | Gateway name. Setting it enables AI Gateway mode. |
| `CF_AI_GATEWAY_PROVIDERS` | `string` | unset | Comma-separated providers routed through the Gateway: `anthropic,openai,google,cloudflare`. |
| `CF_AI_GATEWAY_ACCOUNT_ID` | `string` | unset | Gateway owner account ID. Required whenever `CF_AI_GATEWAY` is set. |
| `CF_AI_GATEWAY_API_TOKEN` | `string` | unset | Run + Read token for inference and cost-log reads. Required whenever `CF_AI_GATEWAY` is set. |
| `CF_AI_GATEWAY_WAI` | `string` | falls back to `CF_AI_GATEWAY` | Optional Workers AI gateway override. |
| `CF_AI_GATEWAY_WAI_DIRECT` | `"true"` | unset | Routes Workers AI to its plain REST endpoint — no gateway, no cost logs. |
| `CF_ACCESS_AUD` | `string` | unset | Cloudflare Access audience. When set, the deployment authenticates via Cloudflare Access (SSO). |
| `CF_ACCESS_ISS` | `string` | unset | Access team URL, e.g. `https://<team>.cloudflareaccess.com`. |
| `AUTH_GATEKEEPERS` | `string` | unset (empty allowlist) | Comma-separated gatekeeper vendor ids permitted to drive sign-in. |
| `DISABLE_PASSWORD_AUTH` | `"true"` | unset (password auth on) | Disables username/password login + signup. Only takes effect when at least one auth gatekeeper is allowlisted. |
| `ENABLE_CLOUDFLARE_LIMITS` | `"true"` | unset (unlimited usage) | Enables the free daily limit + Cloudflare-credits top-up flow. |
| `DAILY_LLM_CALL_LIMIT` | `string` | `DEFAULT_DAILY_LLM_CALL_LIMIT` (100) | Free-tier LLM calls per user per UTC day. |
| `MINIMUM_CLOUDFLARE_BALANCE` | `string` | `MINIMUM_CLOUDFLARE_BALANCE` (2 USD) | Minimum connected-account balance to proceed via BYOK. |

<Note>
Booleans in this surface are string-compared, not coerced. `DISABLE_PASSWORD_AUTH`, `ENABLE_CLOUDFLARE_LIMITS`, and `CF_AI_GATEWAY_WAI_DIRECT` are meaningful only as the exact string `"true"`.
</Note>

## Admin and deployment identity

<ParamField body="ADMINS" type="string[]">
Deployment-wide admin usernames. Declared as an array rather than a comma-separated string.
</ParamField>

<ParamField body="PUBLIC_BASE_URL" type="string">
Public base URL of the deployment. Every gatekeeper OAuth app must be registered against a redirect URI derived from it:

- GitHub: `${PUBLIC_BASE_URL}/gatekeeper/github/oauth`
- Google: `${PUBLIC_BASE_URL}/gatekeeper/google/oauth`
- Cloudflare: `${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth`

In local development this is typically `http://localhost:8787`.
</ParamField>

## AI Gateway mode

Setting `CF_AI_GATEWAY` puts supported providers behind Cloudflare AI Gateway with server-managed keys, so users do not need their own keys. All inference travels over HTTPS with tokens — there is no Workers-binding transport for it — which is why the account/token pair is mandatory in this mode.

```bash title="Gateway mode, minimum viable configuration"
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...
```

The API token needs **AI Gateway Run and Read** permissions. Run executes models; Read lets Gadgets retrieve each log's cost for user-visible accounting. The Gateway may live in the Worker's own account or a different one.

### Workers AI routing

Workers AI uses `CF_AI_GATEWAY` as its Gateway ID by default. Two overrides exist:

| Setting | Effect |
| --- | --- |
| `CF_AI_GATEWAY_WAI=<other-gateway>` | Routes Workers AI through a different Gateway in the same account. |
| `CF_AI_GATEWAY_WAI_DIRECT=true` | Bypasses gateways entirely and calls the Workers AI REST endpoint directly, using the same account/token pair. Produces no cost logs. |

<Warning>
Outside gateway mode, Workers AI (provider `cloudflare`) is BYOK like every other provider — the account ID and API token come from the user's model config, not from `env`.
</Warning>

<Tip>
When using `CF_AI_GATEWAY*` locally, start the dev server with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` tool's document-to-Markdown conversion still has a `WORKERS_AI` binding. Inference itself no longer uses that binding.
</Tip>

## Authentication

Three authentication surfaces coexist and turn on independently. There is no single switch.

```text
                       ┌──────────────────────────────────────┐
  CF_ACCESS_AUD  ────►  │ Cloudflare Access (SSO)              │
  CF_ACCESS_ISS         └──────────────────────────────────────┘
                       ┌──────────────────────────────────────┐
  (nothing set)  ────►  │ username / password  ← default ON    │
  DISABLE_PASSWORD_AUTH └──────────────────────────────────────┘
        │ "true"                        ▲
        └── suppressed unless ──────────┘
            AUTH_GATEKEEPERS non-empty
                       ┌──────────────────────────────────────┐
  AUTH_GATEKEEPERS ──►  │ "Continue with …" per allowlisted    │
                        │ gatekeeper that advertises           │
                        │ providesAuth                         │
                        └──────────────────────────────────────┘
```

<ParamField body="CF_ACCESS_AUD" type="string">
Cloudflare Access audience. When set, the deployment authenticates via Cloudflare Access. Also referenced through a local `Env` extension in `server.ts`.
</ParamField>

<ParamField body="CF_ACCESS_ISS" type="string">
Access team URL, e.g. `https://<team>.cloudflareaccess.com`.
</ParamField>

<ParamField body="AUTH_GATEKEEPERS" type="string">
Comma-separated allowlist of gatekeeper vendor ids permitted to drive sign-in, e.g. `google,github,cloudflare`. Parsed by `getAuthGatekeeperAllowlist(env)`, which splits on `,`, trims, lowercases, and drops empty entries; unset yields an empty list. A listed gatekeeper must also actually advertise `providesAuth` to be offered.

The primary account key is always the user's verified email: signing in with any allowlisted gatekeeper that yields the same verified email maps to the same account.
</ParamField>

<ParamField body="DISABLE_PASSWORD_AUTH" type="string">
`"true"` hides username/password login and signup, leaving gatekeeper sign-in only. `isPasswordAuthEnabled(env)` returns `true` for any value other than the exact string `"true"`, and also returns `true` when `hasAuthGatekeepers(env)` is false — the anti-lockout guard.
</ParamField>

```ts title="packages/workshop-backend/src/auth/config.ts"
export function isPasswordAuthEnabled(env: Cloudflare.Env): boolean {
  if (env.DISABLE_PASSWORD_AUTH !== "true") return true;
  return !hasAuthGatekeepers(env);
}
```

OAuth client id and secret for each connector live on the gatekeeper Workers, not in the backend env. In dev they are seeded into those Workers from shell variables by `run-dev-server.js` via its `SHARED_GATEKEEPER_CREDS` map (`GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET`, `GOOGLE_*`, `CLOUDFLARE_OAUTH_*`); credentials already defined in a gatekeeper's own config still win.

## Usage limits and billing

`ENABLE_CLOUDFLARE_LIMITS=true` turns on the free-tier allowance plus Cloudflare-credits top-up flow. Unset, usage is unlimited. The free-tier counter lives on each `UserDurableObject` (`consumeDailyLlmCall` / `checkDailyLlmCount`) — there is no separate binding. Before each user-initiated agent turn, the overseer calls `checkUsageAndBalance`.

| Situation | Outcome |
| --- | --- |
| Cloudflare connected, balance ≥ `$2` | Allowed and routed through the user's own account (BYOK), even while free-tier allowance remains. Their daily counter is left untouched. |
| Within the free tier | Allowed via the platform's configured AI Gateway. Includes connected users below `$2`, including `$0`. |
| Free tier exhausted, no Cloudflare account connected | Blocked, with a prompt to connect. |
| Free tier exhausted, connected but below `$2` | Blocked, with a prompt to add credits. |

<ParamField body="DAILY_LLM_CALL_LIMIT" type="string" default="100">
Free-tier LLM calls per user per UTC day. Falls back to `DEFAULT_DAILY_LLM_CALL_LIMIT`.
</ParamField>

<ParamField body="MINIMUM_CLOUDFLARE_BALANCE" type="string" default="2">
Minimum connected-account balance in USD required to proceed via BYOK. Falls back to the `MINIMUM_CLOUDFLARE_BALANCE` constant.
</ParamField>

Balance is read live from the user's Cloudflare AI Gateway billing (`/ai-gateway-billing/credit_balance`) and cached for 5 minutes. Top-ups happen in the Cloudflare dashboard; the platform never holds money. Readers for these variables live under `packages/workshop-backend/src/ai-gateway-billing/` (`config.ts`, `limits/config.ts`, `limits/usage-checker.ts`, `cloudflare/account-service.ts`, `cloudflare/connection-service.ts`). On the client, `ServerConfigContext` exposes `cloudflareLimitsEnabled`.

<RequestExample>
```bash title=".dev.vars — public multi-user service"
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

# Seeded into the gatekeeper Workers by run-dev-server.js
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...

# Optional overrides
DAILY_LLM_CALL_LIMIT=100
MINIMUM_CLOUDFLARE_BALANCE=2
```
</RequestExample>

## Optional bindings, not variables

Several capabilities are gated by the presence of a binding rather than by a string variable. They are declared alongside the variables in `env.d.ts`.

| Binding | Type | Behavior when absent |
| --- | --- | --- |
| `BLUEPRINTS` | `KVNamespace` | Required — blueprint metadata lookup. |
| `BLUEPRINT_CONTENT` | `R2Bucket` | Required — blueprint code snapshots. |
| `AVATARS` | `KVNamespace` | Required — user avatar images. |
| `PRODUCT_ANALYTICS` | `Pipeline<ProductAnalyticsRecord>` | Analytics no-op. Local/dev configs omit it. |
| `FRONTEND_ERROR_REPORTER` | `Service<ErrorReporter>` | No reports dispatch. |
| `FRONTEND_ERROR_RATE_LIMITER` | `RateLimit` | No reports dispatch. |
| `BROWSER` | `BrowserRun` | Gadget export rendering unavailable. Optional for self-hosted deployments. |

<Warning>
Browser error reporting requires **both** `FRONTEND_ERROR_REPORTER` and `FRONTEND_ERROR_RATE_LIMITER` to be present before reports dispatch. Binding only one is equivalent to binding neither.
</Warning>

## MCP portal gatekeeper variables

`packages/gatekeeper-mcp-portal` runs as its own Cloudflare Worker and is auto-discovered by the backend from its `GATEKEEPER_MCP_PORTAL` binding. Its configuration is read from that Worker's own env, not the backend's.

| Variable | Meaning |
| --- | --- |
| `MCP_PORTAL_URL` | The portal's MCP endpoint. Unset means the connector hides itself. |
| `MCP_PORTAL_NAME` | Display name in the connector list and every approval prompt. Defaults to `MCP Server Portal (<host>)`. |
| `MCP_PORTAL_AUTH` | `oauth` (default), `none`, or `token`. |
| `MCP_PORTAL_TOKEN` | Secret bearer token, for `MCP_PORTAL_AUTH: "token"`. |
| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default. |
| `MCP_ALLOW_INSECURE` | `"true"` disables endpoint checks entirely: permits `http://` and private, loopback, link-local, and cloud-metadata hosts, for the portal and every OAuth URL discovered from it. Local dev only. |

Only `MCP_ALLOW_INSECURE` is set in the package's committed `wrangler.jsonc`, pinned to `"false"` so the default is explicit rather than merely absent. None of the others is committed: a portal URL in the repo would become the default for every deployment and would send their users' OAuth flows to whichever host it named, so it belongs in the deployment's own configuration.

### `MCP_PORTAL_URL` validity and repointing

<AccordionGroup>
<Accordion title="Unconfigured or unusable URL">
Unconfigured, `getSupportedResources()` returns nothing and the Workshop drops the vendor. A `MCP_PORTAL_URL` that cannot be used — a non-`https` typo, or a URL containing `username:password` — is treated identically, so a misconfiguration hides the connector rather than producing one that fails on first use or copies URL credentials into account state and configurator fields.
</Accordion>
<Accordion title="Changing MCP_PORTAL_URL on a live deployment">
Changing the URL on a deployment that already has connected accounts is a **repoint**, and existing bindings fail closed immediately. The minting path checks facet props against current configuration, and an already-minted facet must name its endpoint when asking the account for credentials — which the account refuses after it has moved. Recovery is reconnecting; that is the one endpoint change an account will accept, and only because the new endpoint comes from the Worker's configuration rather than from a form.

Nothing held for the old portal survives: tokens, the transport session, and any in-progress authorization are dropped, so the user re-authorizes against the new host. The account advances a persisted generation before probing, so refreshes, expiry notifications, and session writes started under the old generation are ignored when they return. Always-approve action kinds also include the exact endpoint, so consent for the old portal does not carry over.
</Accordion>
</AccordionGroup>

### `MCP_PORTAL_TRUST_ANNOTATIONS` evaluation

Read at each point of use via `portalTrust(env)` and never persisted on an account or a binding's props. Consequences:

- Clearing it de-escalates every existing connection on the next call.
- Setting it does not retroactively auto-apply anything; the user must still enable a rule per action kind.

## Frontend variables

`VITE_FRONTEND_ERROR_REPORTING` and `VITE_BACKEND_HOST` are Vite-prefixed and therefore build/dev-time client configuration rather than Worker env.

<ParamField body="VITE_BACKEND_HOST" type="string">
Documented in `run-dev-server.js`. Setting `VITE_BACKEND_HOST=localhost:9000` also passes `--port 9000` to `wrangler dev`, keeping the client's target and the Wrangler listener in sync.
</ParamField>

<Info>
The supplied source evidence documents the two backend error-reporting bindings (`FRONTEND_ERROR_REPORTER`, `FRONTEND_ERROR_RATE_LIMITER`) and `VITE_BACKEND_HOST`, but does not contain the definition or default of `VITE_FRONTEND_ERROR_REPORTING`. Treat the backend bindings above as the authoritative gate on whether reports actually dispatch.
</Info>

## Local development loading order

<Steps>
<Step title="Write .dev.vars at the repository root">
One `KEY=VALUE` per line. Blank lines and lines starting with `#` are skipped; surrounding single or double quotes on the value are stripped. The file is gitignored and may hold local secrets.
</Step>
<Step title="Start the dev server">
`pnpm run dev-server`. `run-dev-server.js` calls `loadDevVars()` before anything else, assigning each key into `process.env` only when `process.env[key] === undefined` — so an exported shell variable always wins over `.dev.vars`.
</Step>
<Step title="Verify gatekeeper discovery">
The script scans `packages/` for directories named `gatekeeper-*` that contain a `wrangler.jsonc`, then writes `wrangler.dev.jsonc` with one service binding per discovered package, named by uppercasing the directory and replacing `-` with `_` (`gatekeeper-github` → `GATEKEEPER_GITHUB`). Confirm the generated file lists the connectors you expect.
</Step>
<Step title="Add flags when needed">
`--use-workers-ai-binding` includes the Workers AI binding in `workshop-backend` (requires Cloudflare login). `--serve-frontend-assets` makes the backend serve the pre-built frontend as static assets instead of relying on Vite on :3000.
</Step>
</Steps>

## Related pages

<CardGroup cols={2}>
<Card title="Configure sign-in and AI Gateway billing" href="/configure-signin-and-billing">
The `AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, and `ENABLE_CLOUDFLARE_LIMITS` posture end to end, including email-keyed identity and the credit top-up flow.
</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
The `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect contract and per-connector `CLIENT_ID`/`CLIENT_SECRET` secrets.
</Card>
<Card title="Local development" href="/local-development">
`.dev.vars` loading, generated dev wrangler files, and the `--use-workers-ai-binding` / `--serve-frontend-assets` flags.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How `GATEKEEPER_*` env keys become `/gatekeeper/<name>/*` routes, plus the full backend binding list.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
The `AdminConfig` schema and why auth configuration is deliberately excluded from it.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Free-tier and balance blocks, a missing `WORKERS_AI` binding in gateway mode, and no-op error reporting without its bindings.
</Card>
</CardGroup>
