AppAIGatewayDocs
Self-hosting

Configuration

Configure a self-hosted gateway, including secrets, sign-in, hostnames and maintenance.

Hosted users manage their apps and providers in the console; none of these settings are needed. This page configures a gateway you run yourself.

A self-hosted gateway is configured through Worker variables and secrets. A variable is plain text you can read back in the dashboard. A secret is write-only: once set, only its name is visible. Provider keys are not configured here at all. You add them in the console, and the gateway encrypts them with the vault described below.

Where a setting lives

PlaceWhat it holdsWhen you use it
The Deploy to Cloudflare formEvery secret name in .dev.vars.example, masked, and every entry in the vars block of wrangler.jsonc, in clear text with its default.The one-click deployment.
The Worker's Settings → Variables in the Cloudflare dashboardAny variable or secret, after deployment.Changing a setting on a deployed gateway without a redeploy from a checkout.
.dev.vars in your checkoutSecrets. pnpm run secrets:upload sends them to the Worker; pnpm run dev reads them locally.Wrangler deployments and local development.
The vars block of a profile overlayPlain variables for one deployment.Anything a deployment profile changes.

One rule keeps those places consistent: a setting is listed in exactly one of .dev.vars.example, where it is a secret, or the vars block of wrangler.jsonc, where it is a plain value. The deploy form masks every name from the first file and prints every value from the second, so a setting that appears in both is asked for twice, once behind asterisks.

Set a secret with Wrangler's hidden prompt or from stdin, never as a command argument:

pnpm exec wrangler secret put SECRET_VAULT_LOCAL_KEK_V2

Wrangler and button deployments can overwrite dashboard edits to plain variables. Keep persistent changes in the configuration used to deploy, or configure Wrangler's keep_vars. CLI updates already use --keep-vars to preserve additional dashboard variables; values explicitly supplied by the deployment configuration still take precedence. Secrets are managed separately from plain variables.

The vault

Provider keys are encrypted at rest. SECRET_VAULT_MODE chooses how, and the choice is explicit: nothing is inferred from which secrets happen to exist, and a missing or mixed configuration is a hard error rather than a quiet fallback.

SECRET_VAULT_MODERequiresUse it when
local (default)SECRET_VAULT_LOCAL_KEK_V1; optionally SECRET_VAULT_LOCAL_KEK_V<n> and SECRET_VAULT_LOCAL_KEK_CURRENT_VERSIONSelf-hosting. AES-256-GCM inside the Worker under a key you hold. No second service.
kmsSECRET_VAULT_KMS_URL, SECRET_VAULT_KMS_TOKENHardened deployments. Envelope encryption through a cf-kms Worker in a separate Cloudflare account, so the root key never enters this account and every decrypt is audited and revocable.

SECRET_VAULT_LOCAL_KEK_V1 is a secret. Make one with:

openssl rand -base64 32

SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION is a plain variable naming which SECRET_VAULT_LOCAL_KEK_V<n> new writes use. Older versions stay accepted for reading, which is what makes rotation possible; see Operations.

A variable from the other mode is an error, not something to ignore: SECRET_VAULT_LOCAL_KEK_V1 present in kms mode, or SECRET_VAULT_KMS_URL present in local mode, marks the vault as misconfigured. GET /v1/healthz reports the result in a non-secret vault field, "ok" or "misconfigured", and the Worker log names the offending variable without printing its value.

Running kms mode

Deploy a cf-kms instance dedicated to this gateway in your security account, on a custom domain, with its own KEK_V1 and a CALLERS entry of:

{ "name": "app-ai-gateway", "requiredContext": { "service": "app-ai-gateway" } }

Then set SECRET_VAULT_MODE=kms on this Worker, remove SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION, and add the URL and the caller token as secrets. Encrypted blobs are self-describing by prefix, so switching modes on a gateway that already holds provider keys is a decrypt-and-re-encrypt migration, and a mismatch fails loudly rather than silently.

Back the vault key up

In local mode, losing every SECRET_VAULT_LOCAL_KEK_V<n> makes the stored provider keys permanently unreadable. Keep a copy outside Cloudflare.

Generated secrets

NamePurpose
JWT_SECRETSigns the short-lived tokens the gateway issues to your apps.
BETTER_AUTH_SECRETSigns console sessions and management keys.

Both are created on the first deployment, by the deploy script or by the one-click flow, and kept unchanged afterwards. For local development, pnpm run secrets:setup-local generates them into .dev.vars. You never supply a value for either.

Sign-in

ALLOWED_REGISTRATION_EMAILS

Plain variable, empty by default. The email addresses that may create an account in the console, separated by commas and matched without regard to case. Each listed person who registers gets an account of their own, with no access to anyone else's. Nobody else can register, and an empty list means nobody can. * lets anyone register.

The list only decides who may register. Removing an address does not remove that person or stop them signing in.

It does not apply to claiming the account agw deployment setup creates. Only the CLI holding that account's key can start a claim, so agw account claim works with any email.

CLI_BOOTSTRAP_TOKEN_DIGEST

Secret, set by agw deployment setup when it deploys the Worker. It is the SHA-256 of the token the CLI then creates its account with, so that account is the CLI's however soon anyone else finds the address. Without it, nothing can create an account without signing in. You never set it yourself.

DEPLOYMENT_ID

An immutable public UUID stored as a plain Worker binding. The deployment helpers generate it automatically and reuse the deployed value on updates and domain changes. The CLI compares this identity with Cloudflare settings before adopting an existing Worker. Do not upload it as a secret.

You normally do not need to set it. An explicit value in the build environment or Wrangler vars is supported for initial setup, but must match the existing identity on updates, and must be 8 to 88 letters, digits, underscores or hyphens. Local setup stores its development value in ignored .dev.vars; pnpm run deploy uses that value only when no deployed or explicitly configured identity exists.

CLI_CONSOLE_ORIGIN

Optional secure first-party origin for browser handoffs. It must serve the same Worker: both the console, which renders the approval page the CLI links to, and the /v1/cli/browser/ routes that page calls. HTTPS is required outside local development. Leave it unset when the API and console share an origin, unless you want MCP clients to connect with OAuth: that needs it set to the console's one canonical address; see MCP clients.

GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET

Secrets. Both must be set for the console to offer sign-in with Google; with either missing the button does not appear. Create an OAuth client in Google Cloud with the authorised redirect URI:

https://YOUR-HOST/v1/auth/callback/google

Google follows the same registration rule as email and password: only a listed email can create an account. A pending claim can create the person who owns its existing account through the scoped Google flow, with any email.

Signing in with Google never joins a sign-in that already exists for the same email address. If you registered that address with a password, keep using the password; Google sign-in is refused for it rather than opening the same account.

OAUTH_RELAY_URL

Plain variable, unset by default. It points at an OAuth callback relay for local instances whose hostname changes, so it cannot be registered with Google. A deployment that owns a stable hostname leaves it unset. See Local development.

Email verification

Unset by default, and then nothing is verified: every sign-in is trusted with the address it gave, and ALLOWED_REGISTRATION_EMAILS is the only check on who registers. Anyone who knows a listed address could register it before its owner does. They would get an account of their own, with no access to anyone else's, and the owner could not register that address until the sign-in was removed.

To verify addresses, give the gateway a way to send email: an SMTP server from any mail provider, or Cloudflare Email Service. Configuring either one turns verification on:

  • A sign-up with a password is sent a link that verifies its address. It lasts 24 hours, and the console offers to send a new one. A Google sign-in is verified by Google and needs none.
  • Until the owner of an account has verified their address, its apps answer every request with email_unverified. Everything else works, so apps, providers and keys can all be set up first.
  • Claiming the account agw deployment setup creates waits for a verified address too: the approval page asks for it after you create your sign-in.
  • Accounts that existed before you turned verification on are unaffected, and keep serving requests whoever owns them.

Both ways need a domain you own, with the records your mail provider asks for (SPF and DKIM), or verification emails land in spam. A workers.dev address cannot send email.

SMTP: SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD

Works on the Workers Free plan, with any provider that offers SMTP sending. Set three plain variables and one secret, plus EMAIL_FROM. Setting any one of them counts as choosing SMTP, so one left out is reported rather than ignored:

NameKindValue
SMTP_HOSTVariableYour provider's SMTP server, for example smtp.example.com.
SMTP_PORTVariable465 (the default) for TLS from the start, or the port your provider gives for STARTTLS, usually 587. Port 25 is not available to Workers.
SMTP_USERNAMEVariableThe account the gateway signs in as, usually the sending address.
SMTP_PASSWORDSecretIts password, or an app password where your provider has them.
EMAIL_FROMVariableThe address emails come from. Bounces come back to it, so use a mailbox you check.
EMAIL_REPLY_TOVariableOptional. Where replies go instead, such as a support inbox.
pnpm exec wrangler secret put SMTP_PASSWORD

The gateway only signs in over an encrypted connection: on a port other than 465 it refuses to send the password if the server does not offer STARTTLS.

Cloudflare Email Service: EMAIL

Needs the Workers Paid plan to send to anyone but yourself.

  1. Onboard your domain to Email Sending, in the dashboard or with pnpm exec wrangler email sending enable example.com.

  2. Add a send_email binding named EMAIL and set EMAIL_FROM to an address on that domain, in your profile overlay:

    {
      "send_email": [{ "name": "EMAIL" }],
      "vars": { "EMAIL_FROM": "gateway@example.com" }
    }

EMAIL_REPLY_TO

Plain variable, unset by default, for either way of sending. Set it to send replies somewhere other than EMAIL_FROM: a dedicated sending mailbox for EMAIL_FROM, whose password the gateway holds, and your support inbox here. Bounces still go to EMAIL_FROM.

EMAIL_SENDER

Plain variable, unset by default. With only one way to send configured, the gateway uses it. With both, set EMAIL_SENDER to smtp or cloudflare to choose; switching is changing this one value.

/v1/healthz reports "email": "ok" once the chosen way is ready to send, "off" with none configured, and "misconfigured" when one is configured but cannot send yet: a setting missing, EMAIL_FROM not an address, or both ways configured without EMAIL_SENDER. Verification stays on while it is misconfigured, and the Worker's logs name the setting to fix when a link cannot be sent.

Hosts

PUBLIC_API_URL

Plain variable, unset by default. Set it when the gateway answers on a second custom domain that your apps call, separate from the one you sign in to. It must be an origin: https, no path, no query. It changes what the console prints as the base URL and which routes answer on that host; see Custom domain and rate limiting.

MCP clients

The MCP server accepts a management key without extra configuration. OAuth through the console's consent page also requires a stable deployment ID and console origin. The deployment helpers generate DEPLOYMENT_ID automatically; CLI deployment setup also configures the console origin for you.

For Wrangler or button deployments, set CLI_CONSOLE_ORIGIN to the one canonical address of your console — https, or for local development http on a loopback host: localhost, 127.0.0.1, [::1] or any name under .localhost, such as http://gateway.localhost:5173. That address is the authorization server every token is issued by and bound to, so it has to be one fixed origin: if the gateway took it from each request, a deployment answering on two names would issue tokens for both, and the nightly cleanup of ended connections, which has no request, would have none. Without either setting, OAuth is off: the /.well-known and /oauth addresses answer 404, access tokens are refused, and clients connect with management keys only. A plain http address on any other host is not a console origin the gateway accepts at all, so it is refused rather than leaving OAuth off; see CLI_CONSOLE_ORIGIN.

MCP_ALLOWED_ORIGINS

Plain variable, unset by default. The browser origins, besides the console's own, that may call the MCP server at /mcp and its OAuth endpoints under /oauth, comma-separated:

wrangler.prod.overlay.jsonc
{
  "vars": { "MCP_ALLOWED_ORIGINS": "https://inspector.example.com,https://agent.example.org" }
}

Each entry is an exact origin: https (or http on a loopback host such as localhost, for local development), host and port, no path. An entry that is not one is ignored and logged as a warning, and the others still apply. A listed origin receives CORS headers and its preflight is answered; a request from any other web page is refused with 403. MCP clients that are not browsers send no origin and are served whatever this says, so most deployments leave it unset. On a deployment reachable through more than one host name, also set CLI_CONSOLE_ORIGIN: without it, every host the Worker answers on counts as the console's, and so does each host's own origin.

OAUTH_CIMD

Plain variable, unset by default, which leaves it on. An MCP client connecting with OAuth identifies itself with an https URL, the address of a Client ID Metadata Document describing it; the gateway fetches that document when the client asks to connect, within fixed bounds: https only, no address or localhost hosts, no redirects, five seconds and 64 KiB. Set it to false to refuse every client that identifies itself this way, so only the clients in OAUTH_CLIENTS can connect:

wrangler.prod.overlay.jsonc
{
  "vars": { "OAUTH_CIMD": "false" }
}

The authorization server's metadata says which: its client_id_metadata_document_supported follows this variable.

OAUTH_CLIENTS

Plain variable, unset by default. An advanced option: OAuth clients you register yourself, for a client that cannot publish a metadata document, as a JSON array of clientId, name and redirectUris:

wrangler.prod.overlay.jsonc
{
  "vars": {
    "OAUTH_CLIENTS": "[{\"clientId\":\"my-tool\",\"name\":\"My tool\",\"redirectUris\":[\"http://127.0.0.1/callback\"]}]"
  }
}

A registered client is a public client, with no secret. Its name is shown on the consent page as yours rather than the client's, so the page does not name a domain for it. Each redirect URI must be absolute, with no fragment: https, http on a host written exactly 127.0.0.1, [::1] or localhost (any port then matches), or an app's own scheme such as com.example.app:/callback. Any other address must match character for character. An entry that breaks a rule is ignored and logged as a warning, and the others still apply. A registered clientId wins over a metadata document of the same address.

Providers

PROVIDER_TTFB_TIMEOUT_SECONDS

Plain variable, default 120. How long a provider may take to send its response headers before the gateway gives up and answers 504. Once headers arrive the body streams for as long as the model needs; only the first byte is bounded. Reasoning models can think for a minute before that first byte, which is why the default is generous. A custom endpoint's fallback chain shares one budget across all of its targets, so the wait for response headers cannot exceed this across the chain. A primary that never answers consumes the whole budget and leaves no time for a fallback. Lowering the value shortens that wait; it does not reserve time for fallbacks.

Nightly maintenance

MAINTENANCE_QUERY_BUDGET

Plain variable, default 50. How many D1 queries the nightly maintenance cron may issue in one run: it prunes sign-in history and spent challenges, expires finished CLI authorizations, and compacts usage history into its rollups.

Every D1 query is a subrequest, and a Worker invocation may issue 50 of them on the Cloudflare Free plan and 1,000 on the Workers Paid plan. The default is the Free number, so a gateway installed with one click stays inside it without being asked anything. The run stops at the budget and continues the next night, which is why exceeding it is never necessary: nothing is lost, only deferred.

Raise it only if you are on the Workers Paid plan and have a backlog worth draining faster — after a long period of heavy traffic, or after restoring an old database. Values below 20 are ignored, and so is anything that is not a whole number. Set it in the dashboard under Settings → Variables, or in a profile overlay; it is deliberately not part of the one-click form.

TERMS_OF_SERVICE_URL and PRIVACY_POLICY_URL

Plain variables, unset by default. When both are set, the sign-up screen shows a consent line linking to them. With either missing, nothing is shown. Set them in a profile overlay or in the dashboard.

Management analytics

MANAGEMENT_ANALYTICS

Analytics Engine dataset binding, unset by default. With it bound, the gateway writes one data point for every management call — from the console, the API, the CLI or an MCP client — so you can see which of those your accounts use, and how often each is refused or fails. Unbound, nothing is recorded and nothing extra runs.

Each point carries the account id, the channel, the credential type, the operation or MCP tool, how the call ended (its HTTP status, error code and, for an invalid request, the top-level field the schema refused), which known client called, such as agw, curl or claude-code (other for any client not on the gateway's list), the client's version, how long the call took, and Cloudflare's request id. It never carries an error message, a request value, a credential or an email address, and a client's name is never copied as sent. Calls your apps make through the gateway are not recorded.

A call refused before sign-in still names the operation it asked for, and its reason says why: credential_missing (nothing sent), credential_malformed (something that cannot be a management key or token), console_header_missing (a console session cookie sent without the console), credential_rejected (a well-formed key or token that is expired or revoked) or session_expired. The first three are usually a client's mistake; the last two a credential that went stale. The client is answered 401 auth_required whichever it is. Every refusal and failure is also logged in the Worker's logs with the same request id, as ray, so a refused call you find in the dataset leads to its log line.

Add the binding in your profile overlay:

{
  "analytics_engine_datasets": [
    { "binding": "MANAGEMENT_ANALYTICS", "dataset": "agw_management" }
  ]
}

Query it with the SQL API. The blobs are, in order: channel, operation, tool, kind, outcome, code, reason, field, issue, credential, identity, client, client version, request id. The doubles are the HTTP status and the duration in milliseconds, and the index is the account id. For example, the refusal rate per channel over the last week:

SELECT blob1 AS channel,
       SUM(_sample_interval) AS calls,
       SUM(IF(blob5 = 'refused', _sample_interval, 0)) AS refused
FROM agw_management
WHERE timestamp > NOW() - INTERVAL '7' DAY
GROUP BY channel

Sign-up analytics

Optional and unset by default. Bind a Worker of your own as ANALYTICS to be told each time an account is created, for your own sign-up reporting. With nothing bound, the gateway reads nothing for it and sends nothing.

wrangler.<profile>.overlay.jsonc
{
  "services": [
    { "binding": "ANALYTICS", "service": "your-analytics-worker", "entrypoint": "AnalyticsHook" }
  ]
}

Your Worker exports that entrypoint with one method, accountCreated(event). The event below is everything it receives, defined in src/analytics/hook.ts. It never includes a key, a password, an email address, a name or the request.

FieldWhat it is
accountIdThe new account's random ID.
userIdThe random ID of the person who created it.
kindcreated, or claimed when a person takes over an account an agent set up.
occurredAtWhen it happened.
countryThe two-letter country Cloudflare placed the sign-up in, or null.
firstTouchWhere the visitor first came from, or null.

firstTouch comes from a first_touch cookie that your own website sets on a first visit, on a parent domain the console shares. The gateway reads only that cookie, only when an account is created and never when the browser sends Do Not Track (which also leaves country empty), and keeps only these fields, each checked and length-limited: referrerHost, landingPath, utmSource, utmMedium, utmCampaign, firstSeenAt and visitorId.

The call runs after the account is saved. Its result is ignored and a failure is only logged, so the bound Worker cannot slow down or change a sign-up.

Summary

NameKindDefault
SECRET_VAULT_MODEVariablelocal
SECRET_VAULT_LOCAL_KEK_V1, SECRET_VAULT_LOCAL_KEK_V<n>SecretRequired in local mode
SECRET_VAULT_LOCAL_KEK_CURRENT_VERSIONVariable1
SECRET_VAULT_KMS_URL, SECRET_VAULT_KMS_TOKENSecretRequired in kms mode
JWT_SECRET, BETTER_AUTH_SECRETSecretGenerated
ALLOWED_REGISTRATION_EMAILSVariableEmpty
CLI_BOOTSTRAP_TOKEN_DIGESTSecretSet by agw deployment setup
DEPLOYMENT_IDVariableAutomatically generated and preserved on updates
CLI_CONSOLE_ORIGINVariableCurrent origin
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETSecretUnset
OAUTH_RELAY_URLVariableUnset
SMTP_HOST, SMTP_PORT, SMTP_USERNAMEVariableUnset (no verification); port 465
SMTP_PASSWORDSecretUnset; required with SMTP_HOST
EMAILsend_email bindingUnset (no verification)
EMAIL_SENDERVariableUnset; required when both are configured
EMAIL_FROMVariableUnset; required to send
EMAIL_REPLY_TOVariableUnset (replies go to EMAIL_FROM)
PUBLIC_API_URLVariableUnset
MCP_ALLOWED_ORIGINSVariableUnset
OAUTH_CIMDVariableUnset (on)
OAUTH_CLIENTSVariableUnset
PROVIDER_TTFB_TIMEOUT_SECONDSVariable120
MAINTENANCE_QUERY_BUDGETVariable50
TERMS_OF_SERVICE_URL, PRIVACY_POLICY_URLVariableUnset
MANAGEMENT_ANALYTICSAnalytics Engine bindingUnset (nothing recorded)
ANALYTICSService bindingUnset

On this page