The deploy pipeline
What pnpm everyapp deploy does from its first line to Live, in order, with every check that can stop it, what the app policy refuses, every flag, release installs (install), release, the Gateway's own deploy API and browser installs, delete, and what a redeploy keeps.
pnpm everyapp deploy deploys the app in the current directory (or an app release) as a private Cloudflare Worker behind the Gateway. The one-sentence model: Cloudflare's own CLI, cf, does everything in the Cloudflare account with your Cloudflare login, including writing the app registry in the Gateway's own D1 with the rules in @every-app/perimeter/registry; binding an app is a redeploy of the Gateway this run builds, and the Gateway makes a bound app live itself on the first request that reaches it. The entry point is packages/cli/src/commands/deploy/index.ts; the pipeline is packages/cli/src/commands/deploy/deployApp.ts. In an app, pnpm run deploy runs the same command (the template's package.json script is everyapp deploy, starter/package.json; the user docs spell it that way); an agent runs pnpm everyapp deploy --json from the app directory.
Error codes on this page link to the errors page; event shapes (step, progress, warning, action_required, error, done) are on the JSON page. Trust boundaries are in docs/security-model.md ("Trust Zones", "Operator Authority (Self-Host)", "Hosted Deploy API"), mirrored at /docs/agent/repo/security-model, and in docs/threat-model.md (assets, actors, trust boundaries, one testable invariant per boundary), mirrored at /docs/agent/repo/threat-model (the register behind it, docs/threat-model-invariants.md, is not mirrored and stays on GitHub); this page links there rather than repeating them.
One credential
The credential is the Cloudflare login (cf auth login, via pnpm everyapp login) or CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID. The Gateway is found through that login (packages/cli/src/lib/gateway/discover.ts); --gateway or EVERYAPP_GATEWAY_URL only picks among several. Every deploy runs deployApp (packages/cli/src/commands/deploy/deployApp.ts) with that login; the Gateway issues no credential of its own.
Self-host is ADR-0003 (adr/ADR-0003-self-host-authority.md): write access to the Gateway's D1 database in the Gateway's Cloudflare account is operator authority. The CLI reads and writes the registry, the install row, the deploy lease, the resource ledger and the restore points with cf d1 query, under the operator's own login or token. The Gateway issues no deploy credential and has no deploy API in self-host; the CLI never calls it over HTTPS except for the live wait's plain GET. Who deployed is recorded on the app row (deployed_by_actor): the login's email, or api-token:<account id> when CLOUDFLARE_API_TOKEN is set (deployerFor, packages/cli/src/lib/gateway/registry.ts). The Gateway's own deployer records deploy-key:<token id> for its deploy API (routes.ts, pipeline.ts in apps/every-app-gateway/src/server/deploy/) and the admin's email for a browser install (install.ts); both are below.
--gateway, else EVERYAPP_GATEWAY_URL (the variable isn't read when the flag is given; the flag has no default of its own, packages/cli/src/commands/flags.ts), is normalized by requestedGatewayUrl (packages/cli/src/lib/gateway/connect.ts): a value new URL() can't parse is INVALID_GATEWAY_URL (--gateway must be a full URL, not "<value>" with "For example https://apps.example.com."; for the variable EVERYAPP_GATEWAY_URL must be a full URL, not "<value>" with fix set it to https://<value without its scheme>, or unset it); anything else keeps its scheme, host, port and path and loses its query, fragment and trailing slashes (https://apps.example.com/?x=1 becomes https://apps.example.com; the function's comment wants no path, but one is kept, not refused). Only its host counts (discovery picks the Gateway whose install row's gatewayHost is that host, checkGatewayUrl compares hosts) and the run's gatewayUrl is always https://<gatewayHost> from the install row; a host that isn't the Gateway found is GATEWAY_URL_MISMATCH (packages/cli/src/lib/gateway/discover.ts: "The Gateway here is <found>, not <host>", "Found in <account>. For <host>, pass its account with --account-id."; from the flag, rerun this command without --gateway "to use <found>"; from the variable, fix unset EVERYAPP_GATEWAY_URL to use <found>).
Flags
From packages/cli/src/commands/deploy/command.ts (install declares the same flags). "Yes" in the last column means the flag takes something over: an agent shows the refusal and asks the person before rerunning with it (TAKEOVER_FLAGS in packages/cli/src/lib/errors.ts makes the rerun authorize: true).
| Flag | Brief (as the command says it) | Needs the person's yes |
|---|---|---|
--gateway <url> |
The Gateway's URL, checked against the Gateway in your Cloudflare account (default: EVERYAPP_GATEWAY_URL) |
no |
--account-id <id> |
The Cloudflare account of the Gateway, when your login sees several (default: CLOUDFLARE_ACCOUNT_ID) |
no |
--allow-delete-durable-object-data |
Deploy even though Durable Object classes were removed, deleting their data | yes (DURABLE_OBJECT_REMOVED) |
--allow-delete-workflows |
Deploy even though Workflows were removed, deleting them and their instances | yes (WORKFLOW_REMOVED) |
--force-dns |
Publish the app's new hostname even where a DNS record answers it (one at the hostname is replaced) | yes (DNS_RECORD_CONFLICT, DNS_UNREADABLE) |
--enable-email-routing |
Turn on Email Routing for the domains the app's inbound addresses (everyapp email set --inbound) name that don't route mail yet (locks their MX and SPF records to Cloudflare) |
yes (EMAIL_ROUTING_DISABLED) |
--no-wait |
Don't wait for the app to go live (a new hostname's certificate included); it goes live on its first visit | no |
--yes, -y |
With no Gateway yet, install one at apps.<zone> without asking (never cf's questions, or the one before replacing a Worker changed outside everyapp) |
yes: it installs a Gateway |
--out <file> |
release only: where to write the release (default: ./everyapp-release.tar.gz) |
no |
--json |
Print NDJSON events on stdout and never prompt (exit 0 done, 1 error, 2 action required, 251/252 bad command or arguments) | no |
--verbose |
Show every step and progress line, the output of what everyapp runs, and an unexpected error's stack trace; also attaches cf deploy to the terminal so cf asks its own questions |
no |
deploy takes no positional. A published release is installed by pnpm everyapp install <source> (a GitHub repository URL for its latest release, a release tarball's URL, or a downloaded everyapp-release.tar.gz), which takes the same flags (the four takeovers included: a release can drop a class or a Workflow, and its hostname and inbound domains are checked the same way; packages/cli/src/commands/install/command.ts spreads appTakeoverFlags from packages/cli/src/commands/flags.ts), and packed by pnpm everyapp release [--out <file>]. See "Release installs" and "release: packing a release".
There is no --skip-migrations flag. migrate (deployApp.ts) returns before emitting anything for an app with no D1 bindings and no migrations.dialect: "postgresql": no app.migrations event at all. app.migrations with status skip and "No migrations to apply." (skipMigrations) is emitted for a D1 app whose migration binding has no provisioned database id or whose migrations directory is missing, and for a Postgres app whose migrations directory is missing (drizzle: migrations.dir, default drizzle; Prisma: only a migrations.dir the manifest names, since Prisma finds its migrations through its config).
The self-hosted pipeline, step by step
Every step is a step event with status: "start", "ok" or "skip"; the step names below are the ones the code emits (grep step: " in packages/cli/src/commands/deploy, packages/cli/src/commands/appGateway.ts, packages/cli/src/lib/gateway and packages/cli/src/lib/app). Under --json the first line a person would read is the app.deploy event.
| Order | Step | What it does | Can stop with |
|---|---|---|---|
| 0 | (before any step) | Flag checks, release fetch, manifest load, Postgres URL, cloudflare.config.ts, the checkout |
INVALID_GATEWAY_URL, release codes, RELEASE_MIGRATIONS_UNSUPPORTED, POSTGRES_URL_MISSING, MISSING_CLOUDFLARE_CONFIG, CHECKOUT_MISSING |
| 1 | app.connect (app.login inside) |
Cloudflare login, Gateway discovery, install row | cloudflare_login (action, waiting), CLOUDFLARE_TOKEN_REJECTED, CLOUDFLARE_LOGIN_FAILED, install_gateway (action), choose_account (action), GATEWAY_NOT_FOUND, GATEWAY_NOT_INSTALLED, GATEWAY_URL_MISMATCH, CANCELLED |
| 2 | app.deploy |
The first line: what is deployed where, firstDeploy |
DOWNGRADE_REFUSED, IDENTITY_KEY_UNKNOWN |
| 3 | app.register |
Writes the registry row (names assigned once), tier check | registry codes (below), SHARED_APP_FORBIDDEN, TIER_POLICY |
| 4 | app.domain |
DNS preflight of a new hostname | ZONE_CUSTOM_DOMAIN_LIMIT, DNS_UNREADABLE, DNS_RECORD_CONFLICT |
| 5 | app.lease |
Takes app:<workerName> in _everyapp_deploy_lease |
LEASE_HELD |
| 6 | (under the lease) | Dropped Durable Object classes and Workflows | DURABLE_OBJECT_MOVED, HELPER_DURABLE_OBJECT_REMOVED, DURABLE_OBJECT_REMOVED, WORKFLOW_REMOVED |
| 7 | app.resources (app.hyperdrive) |
Claims the Worker(s), plans mail routes, creates or links Secrets Store secrets, D1, KV, Hyperdrive, queues, Vectorize; names R2 | WORKER_NAME_TAKEN, RESOURCE_OWNED, email codes, SECRETS_STORE_MISSING, SECRETS_STORE_VALUE_MISSING, RESOURCE_NAME_MISSING, RESOURCE_NAME_TAKEN, RESOURCE_REPLACED, HYPERDRIVE_ORIGIN_MISSING, DATABASE_URL_INVALID, QUEUE_NAME_INVALID, VECTORIZE_NAME_TOO_LONG, VECTORIZE_CONFIG_MISMATCH |
| 8 | app.install |
Installs dependencies (not for a release, not with install: false) |
INSTALL_FAILED |
| 9 | app.build |
cf build (or the manifest's build), the install rewrite, the policy check, the R2 buckets the Workers bind (made when missing), Workflow names |
BUILD_FAILED, INSTALL_INPUTS_MISSING, INSTALL_PLACEHOLDER_LEFT, WORKFLOW_NAME_TOO_LONG, APP_POLICY, R2_NOT_ENABLED, WORKFLOW_NAME_TAKEN |
| 10 | app.schema |
drizzle-kit generate against a copy of the migrations | SCHEMA_DRIFT, SCHEMA_CHECK_FAILED (warning SCHEMA_CHECK_SKIPPED) |
| 11 | (under the lease) | Is the serving version everyapp's? | rerun_interactive (action), CANCELLED |
| 12 | app.migrations |
Lease check, bookmark, cf d1 migrations apply (or drizzle-kit / Prisma on Postgres) |
LEASE_LOST, MIGRATION_FAILED, DRIZZLE_KIT_MISSING, PRISMA_MISSING |
| 13 | app.helper (per helper Worker) |
cf deploy --prebuilt --worker <helper> |
LEASE_LOST, cf codes (below) |
| 14 | app.upload |
cf deploy --prebuilt of the app's Worker, version recorded at once |
LEASE_LOST, rerun_interactive, register_workers_subdomain, upgrade_workers_paid, DEPLOY_NOT_UPLOADED, VERSION_UNKNOWN, QUEUE_HANDLER_MISSING |
| 15 | app.workflows |
Deletes the Workflows the manifest dropped (with --allow-delete-workflows) |
|
| 16 | app.email |
Makes or removes the app's Email Routing rules | LEASE_LOST, EMAIL_ADDRESS_TAKEN |
| 17 | (under the lease) | markDeployed: version, manifest, actor and the Worker's secret names (apps.secret_names) on the row; pending → binding |
APP_NOT_FOUND, APP_CHANGING |
| 17a | app.secrets |
The manifest's settings against the Gateway's names and the Worker's own; an unmet required setting warns APP_NEEDS_SETUP ("<app> won't open until <NAMES> is set", data.setup) and adds the opening configure <app> to next; never stops the run |
warning only: APP_NEEDS_SETUP |
| 18 | app.bind (gateway.upgrade, gateway.release, gateway.lease, gateway.domains, gateway.migrations, gateway.deploy) |
Redeploys the Gateway from this checkout to bind the Worker and publish its hostname, when the install row doesn't yet | CLI_TOO_OLD, LEASE_HELD, DOWNGRADE_REFUSED, GATEWAY_BUILD_FAILED, GATEWAY_NOT_INSTALLED, rerun_interactive, MIGRATION_FAILED, DNS codes, cf codes |
| 19 | app.live |
Requests https://<hostname>/ until the registry says active |
APP_NOT_LIVE, APP_NOT_FOUND |
| 20 | app.access (gateway.lease inside) |
Behind Cloudflare Access, lets the manifest's public routes (and /mcp where it sets mcp) through (under the access lease); never stops the run |
warnings only: ACCESS_NOT_CHECKED, ACCESS_SYNC_FAILED, ACCESS_ROUTE_NOT_EXPRESSIBLE, ACCESS_TOO_MANY_PUBLIC_ROUTES, ACCESS_APP_OUTSIDE_BASE, ACCESS_FOREIGN_DESTINATION |
| 21 | done |
done.data: url, workerName, versionId, live, organization, publicRoutes, mcp, next |
Before the first step
deploySteps (deployApp.ts) runs these before app.connect:
- A release with
migrations.dialect: "postgresql"is refused:RELEASE_MIGRATIONS_UNSUPPORTED, "can't deploy from a release: its Postgres migrations need the source"; fixclone it, then run pnpm install in it and this checkout's pnpm everyapp deploy from its directory. - An app with Postgres migrations or Hyperdrive configs reads
.everyapp/secrets.env(packages/cli/src/lib/app/deploySecrets.ts, dotenv syntax, kept out of git byensureGitignored). Postgres migrations with noPOSTGRES_DATABASE_URLthere:POSTGRES_URL_MISSING, fixset POSTGRES_DATABASE_URL in .everyapp/secrets.env, then deploy again. Nothing in that file reaches the Worker, is printed or is recorded. - No
cloudflare.config.ts(not for a release):MISSING_CLOUDFLARE_CONFIG. One that doesn't useeveryAppWorker(): warningNONSTANDARD_CLOUDFLARE_CONFIG, "The deploy checks the Worker it builds against the app policy." - The Gateway a rebind would deploy is built from the checkout everyapp runs in (
gatewayFor,packages/cli/src/commands/appGateway.ts, throughcheckoutGateway,packages/cli/src/lib/gateway/checkoutRelease.ts), so outside one the run stops here:CHECKOUT_MISSING("everyapp has to run from a checkout of the EveryApp repository",packages/cli/src/lib/checkout.ts; fixin your checkout, run pnpm everyapp <this command>).
app.connect: the login and the Gateway
connectToGateway (packages/cli/src/lib/gateway/connect.ts): cf auth whoami, then login if needed, then discovery.
- Login (
packages/cli/src/lib/cf/account.ts): at a terminalcf auth login; under--jsonthe run emitsaction_requiredwithaction: "cloudflare_login"on stepapp.login,data.url,data.code,data.expiresAt,data.waiting: true, and waits for the person to approve the device link (cf auth login --no-browser). Piped without--jsonit stops with the same action and no wait. A rejectedCLOUDFLARE_API_TOKENisCLOUDFLARE_TOKEN_REJECTED("A token needs Workers, D1, KV and Zone DNS Read access"); a login that didn't complete isCLOUDFLARE_LOGIN_FAILED. - Discovery (
discoverGateway,packages/cli/src/lib/gateway/discover.ts) probes each account the login lists for the Gateway's D1 (every-app-gateway) and reads its install row.--account-id, elseCLOUDFLARE_ACCOUNT_ID, names the account (it is probed even when the login doesn't list it: an API token may list none) and the Gateway is that account's or none. Without it, one Gateway found is the one; with several, the--gatewayhost matching exactly one install row'sgatewayHostpicks it, a terminal asksWhich Gateway?(each by its host, the account beside it), and an unattended run stops withaction_requiredchoose_account("Choose which Gateway to use", "<email>sees a Gateway in<n>Cloudflare accounts. Run one of:"),rerun: null, anddata.choices[]of{ accountId, accountName, gatewayUrl, label, rerun }, eachrerunthis command with--account-id <id>appended, ready to run. GATEWAY_NOT_FOUND(requireGateway,discover.ts;data.accountslists the accounts probed) has three forms: the named account has no Gateway while others do ("Cloudflare account<id>has no Gateway", "This login's Gateway is in<account> at https://<host>"; fixpass that account's id to --account-id (or CLOUDFLARE_ACCOUNT_ID), or drop it); the login lists no accounts and none was named ("This Cloudflare login lists no accounts"; fixpass the Gateway's account with --account-id, or set CLOUDFLARE_ACCOUNT_ID); none anywhere ("No Gateway in<account>, the only account this login sees" or "No Gateway in the<n>accounts this login sees", "Or log in to the Gateway's account: pnpm everyapp login"; rerunpnpm everyapp setup --domain <hostname>"to install one"). Fordeploythe last case is normally taken over by the install offer below, which runs when no account has a Gateway and at least one was probed.- None anywhere:
deploycallsofferGateway(packages/cli/src/commands/deploy/steps/offerGateway.ts). At a terminal it offersInstall a Gateway at apps.<zone>?(the zone the only active one or a choice), runs setup'sinstallGatewayinline (owner link included), then discovers again;--yesinstalls without the question ("<account>has no Gateway yet, so --yes installs one atapps.<zone>first."); No isCANCELLED("Cancelled. Nothing deployed."). The account isresolveAccount's (packages/cli/src/lib/cf/account.ts):--account-id, thenCLOUDFLARE_ACCOUNT_ID, then the only account the login lists, else the choiceWhich account should the Gateway go in?; one named that the login lists but can't use isACCOUNT_NOT_ACCESSIBLE(the message lists the usable accounts; fixpass one of these with --account-id, or log in as someone else, rerunpnpm everyapp login), a login listing no accounts with none named isACCOUNT_REQUIRED(fixpass --account-id, or set CLOUDFLARE_ACCOUNT_ID). Unattended (--json, piped) it stops withaction_requiredinstall_gateway, "Install a Gateway first:<account>has none yet", rerunpnpm everyapp setup --domain apps.<zone> [--account-id <id>],data.accounts,data.domain; the suggested host isapps.<first active zone>of the account the same lookup picks without a terminal, and when that lookup fails (several accounts, or eitherACCOUNT_*code; it is swallowed) the rerun says--domain <hostname>literally anddata.domainis absent. - An install row whose first upload never finished (
liveVersionIdnull) or a database with no row:GATEWAY_NOT_INSTALLED, rerunpnpm everyapp setup(requireInstall,connect.ts;notDeployedByEveryapp,packages/cli/src/lib/gateway/install.ts).
app.connect ends with data.gatewayUrl. The connection carries cf pinned to the account, the D1 handle, the install row (_everyapp_install: account, zone, gatewayHost, appBaseDomain, the live release and version, liveDomains, liveAppWorkers, jwtPublicKey, deploymentMode) and the deployer.
app.deploy: the first line and the guards
app.deploy is emitted ok at once, before anything can stop the run: message Deploying <appId>[ <version> from <origin>] to <gatewayUrl>.; data: appId, organizationName (the install organization's, else the account's), firstDeploy (true when the app isn't registered or its status is pending or binding: NEVER_LIVE), gatewayUrl, gatewayHost, and for a release version, origin, release.{version,gitSha,origin,sha256}. A person sees Deploying todo to apps.example.com (Acme).
Then, before the registry changes:
refuseOlderCheckout(packages/cli/src/lib/gateway/apply.ts): the Gateway this run would deploy (checkoutSource,packages/cli/src/lib/gateway/checkoutRelease.ts: the checkout's tree hash, version, newest migration and last commit time) is compared with the install row byolderThanInstalled: the same SHA-256 or the same tree hash is never older; otherwise by newest migration, then by when the sources were last committed. Older isDOWNGRADE_REFUSED: "This checkout's Gateway (X) is older than the live one (Y)", fixgit pull, rerun the same command "after that". Onlysetup --allow-downgradeputs an older Gateway back.- No
jwt_public_keyon the install row:IDENTITY_KEY_UNKNOWN("The Gateway is missing its sign-in public key", "App Workers need it to check who is signed in."), fixrestore JWT_PUBLIC_KEY as in https://github.com/every-app/every-app/blob/main/docs/runbooks/key-rotation.md(the runbook is mirrored at /docs/agent/repo/runbooks/key-rotation).
app.register: the registry row
registerFromManifest (packages/cli/src/commands/appGateway.ts) → register (packages/cli/src/lib/gateway/registry.ts) runs the pure rules in packages/perimeter/src/registry/registration.ts and writes one cf d1 query batch, which D1 applies atomically.
- The install organization is the oldest with an owner, else the oldest of all; none yet: one is created from the Cloudflare account's name (slugified;
My Organizationwhen blank) for the setup claim to adopt. The organization's key is claimed fromorgKeyCandidates(slug)(its slug's[a-z0-9]up to 12 characters, then with2,3, … appended) intoorganization_keys; all taken isORG_KEY_UNAVAILABLE. - Names are assigned once, at first registration, and kept by a tombstone: the Worker
every-<orgKey>-<appId>, its D1, KV and R2 after it (every-<orgKey>-<appId>--<binding>with the binding lowercased and_as-, except that the bindingsDB,KVandR2take the Worker's own name:resourceNameForandbucketNameForinpackages/perimeter/src/manifest/manifest.ts), the hostname<appId>.<appBaseDomain>for the install's own organization (<appId>-<orgKey>.<base>for any other). Refusals from the rules, each aRegistrationErrorturned into an error of the same code:RESERVED_APP_ID,INVALID_APP_ID(an id holding--, the resource separator),INVALID_MANIFEST(also an R2 bucket name Cloudflare refuses),INVALID_APP_HOSTNAME,INVALID_WORKER_NAME(over 63 characters, or one of the Gateway's own names),HOSTNAME_TAKEN,WORKER_NAME_TAKEN(another app's row, tombstones included),RESOURCE_NAME_CONFLICT(two bindings of one kind sharing a name),SHARED_APP_NOT_PLATFORM,APP_SCOPE_MISMATCH(anorgapp asked for assharedor the reverse),APP_TIER_MISMATCH(adispatchrow, which only the Gateway's own deployer writes, or aproviderrow fromeveryapp mcp on, deployed from the CLI). - A new app (or a deleted one of its own organization coming back,
revived) is writtenpendingwith its initial access: the deployer (by verified email), owners and admins; withaccess: "everyone"every member andis_default. Anexistingrow only gets its name and description updated: a redeploy never changes names, status, scope or access. - A row already
shared(the CLI passes noscopetoregister,registry.ts: a new app isorgand a redeploy keeps the row's) needs a login that is an owner or admin of the platform organization:SHARED_APP_FORBIDDEN. An API token has no person to check and passes. manifestTierProblems(packages/perimeter/src/manifest/manifest.ts) runs on the row's tier:service_bindinghas no limits;dispatchrefuses Hyperdrive, R2, Workers AI (resources.ai), Browser Rendering (resources.browser), Images (resources.images), Vectorize (resources.vectorize), AI Gateways (resources.aiGateway), Email sending (resources.sendEmail), Secrets Store (resources.secretsStore), Analytics Engine (resources.analyticsEngine),email.inbound: trueandsecrets; any other tierapps on tier "<tier>" can't be deployed. Problems areTIER_POLICY, fixchange everyapp.config.ts, then deploy again.
app.register is emitted ok: Registered <appId> as <workerName> at https://<hostname> (pending). (or Updating … (active)), data: appId, workerName, hostname, status, existingApp, firstDeploy, defaultAccess, deployerAccess, hasOwner, publicRoutes ([{ path, methods }], what the manifest opens to anyone on the internet), mcp ({ by, why } when the manifest sets it: its host answers /mcp; else null), scope, organizationId, organizationName, gatewayUrl. A person sees nothing here; the data becomes the Public fact at the end on every deploy with public routes, the MCP fact (Serves /mcp on its own host (gateway): <why>) on every deploy whose manifest sets mcp, and the Access fact on a first deploy.
app.domain: the hostname preflight
preflightAppHost (packages/cli/src/lib/domains/preflight.ts). A hostname not exactly one label below appBaseDomain is outsideBase: never published, neither checked nor a reason to redeploy the Gateway, and the run ends with warning APP_HOST_OUTSIDE_BASE. A hostname the install row doesn't list in liveDomains is newHost and is checked now, before anything is created or uploaded (the rebind checks again):
- Over 100 Custom Domains in the zone:
ZONE_CUSTOM_DOMAIN_LIMIT; at 95 or more, warningZONE_CUSTOM_DOMAIN_LIMIT_NEAR. cf dns records list -z <zone> --name-exact <host>: anA,AAAA,CNAMEorNSrecord at the name, or a wildcard (*.<parent>) over a name with no records of its own, isDNS_RECORD_CONFLICT: "<host>already points somewhere else … Publishing it replaces the record." With--force-dnsthe record is replaced and warningDNS_RECORD_REPLACEDsays so. A login that can't read the zone's records:DNS_UNREADABLE, fixgive the API token Zone → DNS → Read, rerun with--force-dns(authorize).- A record at the name that is the Gateway's own Custom Domain (Cloudflare's placeholder
AAAA 100::, left by a run that stopped after publishing the hostname;cf workers listsays which Worker holds it) is no conflict: aprogressline says "<host>is already this Gateway's Custom Domain (an earlier run published it, then stopped)". Which Worker holds a hostname with no record in the way isn't read; unless cf is attached (--verboseat a terminal), warningCUSTOM_DOMAIN_OWNER_UNCHECKED: "If another Worker already serves<host>, it will be replaced by this deployment."
app.lease: one deploy per app
acquireAppLease (packages/cli/src/lib/gateway/lease.ts) takes the row app:<workerName> in _everyapp_deploy_lease (the CLI creates the table itself) with purpose app.deploy, the app id, the login's email and the machine name. A lease expires 3 minutes after its last renewal; a heartbeat renews it every 30 seconds (each renewal given up after 20 seconds), so a dead deploy frees it within 3 minutes while a live one holds it as long as it runs. A run waiting on another says so as progress on app.lease ("Waiting for a deploy of todo to finish (ben@example.com on Bens-MacBook-Pro, started 1m ago)") and emits app.lease ok ("The other deploy finished.") once it gets the lease. A waiting run retries the acquire every 5 seconds; after 5 minutes of waiting: LEASE_HELD, retryable, rerun "once it's done (pnpm everyapp status shows it)", data.holder. A renewal that fails emits warning LEASE_HEARTBEAT ("Can't reach Cloudflare to keep hold of <target> (<time> so far)", with how long until another run can take over). The lease counts as lost in two cases (renewOnce): a renewal that returns no row (the row is another holder's, or gone), or renewals that have kept failing for more than 90 seconds, half the TTL (then the heartbeat warns once more: "This run lost its hold on <target> … It stops before its next change."). lease.check(), called right before migrating and before each cf deploy, renews once more and throws LEASE_LOST ("Stopped: this run lost its hold on <target>", "Its hold expired, so it stopped before its next change to avoid overlapping another run.", retryable) when it is lost. Ctrl+C frees the lease. The lease is released in a finally once the registry records the upload; the rebind that follows takes the gateway lease instead.
Dropped Durable Object classes and Workflows
Under the lease, before anything is created (checkDurableObjects, checkWorkflows in deployApp.ts), the manifest is compared with plan.previousManifest, the one the last deploy recorded:
| Code | When | Message and next |
|---|---|---|
DURABLE_OBJECT_MOVED |
A class now names another worker than before |
"<class> can't move to another Worker. A Durable Object's data can't follow it."; fix give the class a new name in everyapp.config.ts |
HELPER_DURABLE_OBJECT_REMOVED |
A helper Worker's class is gone from the manifest | "Removing a helper Worker's Durable Object class isn't supported yet."; fix keep declaring <class> in everyapp.config.ts; --allow-delete-durable-object-data doesn't cover it |
DURABLE_OBJECT_REMOVED |
The app's own class is gone and --allow-delete-durable-object-data isn't set |
"Deploying would delete <class>'s data for good"; rerun with the flag, authorize: true |
WORKFLOW_REMOVED |
A Workflow the last manifest declared is gone, still runs on the Worker that defined it, and --allow-delete-workflows isn't set |
"Deploying would delete the Workflow <binding> and its instances … Renaming a binding renames its Workflow."; rerun with the flag, authorize: true |
With the flag, the removed classes become tombstones in the install rewrite (state: "deleted"), and the removed Workflows are deleted after the upload (app.workflows).
app.resources: provisioning and naming
claimAppWorker and provisionAppResources (packages/cli/src/lib/app/resources.ts), with the ledger in packages/cli/src/lib/app/ownership.ts (_everyapp_app_resources in the Gateway's D1: worker_name, kind, binding, name, resource_id, version_id; unique on (kind, resource_id) and (kind, name)). A resource's name proves nothing: an existing Worker, database, namespace or config is used only when the ledger records it as the app's; a new one is recorded (with resource_id null) before it is created, so a retry after any failure finds it again.
| Resource | Name | Created by | Refusals |
|---|---|---|---|
| Worker | every-<orgKey>-<appId>; helpers <worker>--<name> (helperWorkerName, @every-app/sdk/cloudflare, at most 63 characters) |
cf deploy (claimed first) |
WORKER_NAME_TAKEN: a Worker of that name exists in the account and no deploy of this app created it; RESOURCE_OWNED: the ledger records the name as another app's |
| D1 | <worker>--<binding> with the binding lowercased and _ replaced by -; the binding DB takes the Worker's own name (resourceNameFor, packages/perimeter/src/manifest/manifest.ts; assigned at registration, plan.resources.d1) |
cf d1 create --name <name> |
RESOURCE_NAME_MISSING (no usable name assigned; rerun pnpm everyapp setup), RESOURCE_NAME_TAKEN (exists, not the app's), RESOURCE_REPLACED (the recorded one was deleted and someone else created this one), RESOURCE_OWNED |
| KV | As D1; the binding KV takes the Worker's own name |
cf kv namespaces create --title <name> |
the same three |
| R2 | As D1; the binding R2 takes the Worker's own name (bucketNameFor) |
Not by the CLI: resources.ts only assigns the name and leaves the bucket to cf deploy --prebuilt (its header: creating one "is left to cf's prebuilt deploy, which should create a bucket a Worker binds (unconfirmed on a live account)"); never recorded or purged ("a later change") |
INVALID_MANIFEST at registration for a name Cloudflare refuses |
| Hyperdrive | <worker>--<binding> with the binding lowercased and _ replaced by - (hyperdriveNameFor) |
From the origin in .everyapp/secrets.env: <BINDING>_ORIGIN_URL, or POSTGRES_DATABASE_URL when it is the app's only config; query caching per the manifest's caching (off by default) |
HYPERDRIVE_ORIGIN_MISSING (creating needs the URL; names the variable for this app and the file, and status repeats it while the app is pending), DATABASE_URL_INVALID (names the variable, never the value), RESOURCE_NAME_TAKEN (fix: set the variable to the database it connects to, then it is adopted), RESOURCE_REPLACED |
| Queue | <worker>--<binding> with the binding lowercased and _ replaced by - (queueNameFor, packages/perimeter/src/manifest/manifest.ts); at most 63 characters |
cf queues create --queue-name <name> (packages/cli/src/lib/cf/queues.ts), found first in one cf queues list by exact queue_name; recorded as kind queue with its queue_id. Its consumers are published by cf deploy from the consuming Worker's queue trigger |
QUEUE_NAME_INVALID (checked for every queue before anything is created), RESOURCE_NAME_TAKEN (a queue of the name that no deploy of this app recorded: a queue holds messages, so one is never adopted), RESOURCE_REPLACED, RESOURCE_OWNED |
| Workers AI | None: account-wide | Nothing: the build binds { type: "ai" } on every Worker for each name in resources.ai; never recorded or purged, and billed to the account |
None here; the policy check (below) refuses an ai binding resources.ai doesn't declare |
| Browser Rendering | None: account-wide | Nothing: the build binds { type: "browser" } on every Worker for each name in resources.browser; never recorded or purged; billed to the account, which needs Workers Paid |
None here; the policy check (below) refuses a browser binding resources.browser doesn't declare |
| Images | None: account-wide | Nothing: the build binds { type: "images" } on every Worker for each name in resources.images; never recorded or purged; transformations billed to the account |
None here; the policy check (below) refuses an images binding resources.images doesn't declare |
| Vectorize | <worker>--<binding> with the binding lowercased and _ replaced by - (vectorizeNameFor, packages/perimeter/src/manifest/manifest.ts; at most 64 characters) |
cf vectorize get <name>, then cf vectorize create --body @<file> with { name, config: { dimensions, metric } } or { name, config: { preset } } (packages/cli/src/lib/cf/vectorize.ts); recorded first, the name as its id |
VECTORIZE_NAME_TOO_LONG (checked for every index before any is looked up), RESOURCE_NAME_TAKEN (an index of the name the app didn't record, whatever its shape), VECTORIZE_CONFIG_MISMATCH (the recorded index has other dimensions or another metric, or isn't the declared preset: an index can't be reshaped), RESOURCE_OWNED |
| AI Gateway | <worker>-<binding> with the binding lowercased and _ replaced by -, joined by one hyphen (aiGatewayIdFor, packages/perimeter/src/manifest/manifest.ts): a gateway id is lowercase letters and digits joined by single hyphens, at most 64 characters, so it can't hold -- |
cf ai-gateway gateways create --body @<file> (packages/cli/src/lib/cf/aiGateway.ts): collect_logs from collectLogs (true by default), cache_ttl from cacheTtl (0, nothing cached, by default), no rate limit, authentication: false; recorded under ledger kind ai-gateway with the id as resource_id |
RESOURCE_NAME_TAKEN (a gateway of the id exists and no deploy of this app recorded it: a gateway holds logs of prompts and answers, so it is never adopted), RESOURCE_OWNED, INVALID_MANIFEST (an id Cloudflare would refuse; the strict validator refuses it first) |
| Secrets Store secret | <worker>--<binding> with the binding lowercased and _ replaced by - (storeSecretNameFor), in the account's store: its only one; with several, the one named everyapp, else the first; the store a secret was recorded in, once there is one |
First of all the resources, before anything is created: the store is found (cf secrets-store stores list), each secret looked up by exact name (secrets list --store-id <store> --search <name>), and every value a missing one needs checked; then cf secrets-store secrets create <store> --body @<file> with [{ name, value, scopes: ["workers"] }], the value from <BINDING> in .everyapp/secrets.env (never argv, output or the ledger). Recorded as <store id>/<secret id>. A recorded secret is bound as it is: a redeploy never rewrites its value |
SECRETS_STORE_MISSING (no store: make one once in the dashboard; cf can't), SECRETS_STORE_VALUE_MISSING (creating needs the value; names the variable), RESOURCE_NAME_TAKEN, RESOURCE_REPLACED, RESOURCE_OWNED |
| Analytics Engine | dataset as written; <worker>--<binding> with the binding lowercased and _ replaced by - otherwise (analyticsDatasetFor) |
Nothing: a dataset exists once written to; the build binds { type: "analytics-engine-dataset", name } on every Worker; never recorded or purged (Cloudflare has no delete; data expires after 90 days) |
None here; the policy check (below) refuses an analytics-engine-dataset binding resources.analyticsEngine doesn't declare, or one pointed at another dataset |
A Hyperdrive config not recorded as the app's is adopted when it already connects to the origin in the secrets file ("Adopted …"); a recorded one whose host, port, database or user differ is pointed at it ("Pointed … at the database in .everyapp/secrets.env."); caching is turned back to what the manifest says. A recorded AI Gateway whose logging or caching differs from the manifest is set back with cf ai-gateway gateways update (a PUT: every other setting it reads back is sent back as it was), saying Turned off request logs on AI Gateway "<id>" (collectLogs: false)., Turned on request logs on …, Set AI Gateway "<id>" to cache responses for <n> s (cacheTtl). or Turned off response caching on … (no cacheTtl).. Each is a progress line on app.resources. A recorded resource that no longer exists is created again, empty, with warning RESOURCE_RECREATED. A create that loses a race (already_exists) forgets the claim and throws RESOURCE_NAME_TAKEN ("was created by someone else while this deploy was creating it"). Vectorize indexes come after Hyperdrive and before the build is installed: cf's deploy makes no index, and refuses a Worker that binds a missing one ([10159] Vectorize binding '<NAME>' references index '<name>' which was not found). app.resources ends ok ("The app's resources are ready."); a Hyperdrive config created this run adds app.hyperdrive ok with data.caching.
resources.sendEmail provisions nothing: Cloudflare's send_email binding reaches the account's Email Sending with no resource of the app's own, so there is no name, no ledger row and nothing for delete --purge; the policy check below holds the built binding to the manifest.
Mail is planned here too, under the lease (planEmailRoutes, packages/cli/src/lib/app/emailRoutes.ts), so everyapp email set can't change the lists between the check and the routes made after the upload: the installation's inbound addresses are read from the row; a manifest that no longer declares inbound mail while addresses are stored gives warning EMAIL_INBOUND_UNDECLARED and removes the routes. Refusals before anything is created: EMAIL_ADDRESS_TAKEN, EMAIL_CATCH_ALL_NOT_APEX, EMAIL_CATCH_ALL_TAKEN, EMAIL_ZONE_NOT_FOUND, and for a domain whose Email Routing is off, EMAIL_ROUTING_DISABLED unattended ("Turning it on locks the domain's MX and SPF records to Cloudflare. Nothing was changed."; data.commands lists npx cf email-routing enable …), or the question Turn on Email Routing for <domains>? at a terminal; --enable-email-routing answers it. Details on the email page.
app.install and app.build
app.install runs the manifest's install command, or the detected package manager's install, unless install: false or the deploy is from a release; output goes to a log file, a failure is INSTALL_FAILED (packages/cli/src/lib/package-manager.ts).
app.build (packages/cli/src/lib/app/buildApp.ts): cf build with EVERYAPP_BUILD=1 (without it everyAppWorker() refuses a production build), no --mode (cf refuses it for TanStack Start; Vite builds production by default), 10 minutes at most, or the manifest's build command through a shell with the same variable. A failure is BUILD_FAILED with cf's last lines. The output is the Build Output .cloudflare/output/v0 (packages/cli/src/lib/app/buildOutput.ts): a root config.json and workers/<name>/worker.config.json plus bundle/, default for the app's Worker.
The install rewrite
Every production build is tenant-neutral: everyAppWorker() builds from sentinelDeployTarget() (packages/sdk/src/cloudflare/sentinel.ts), so the same output comes out whoever builds it, with placeholders where an install's values belong: BUILD_SENTINEL.workerName every-hosted--sentinel, organizationId everyapp-hosted-deploy-sentinel, gatewayUrl https://every-hosted--sentinel.invalid, accountId every-hosted--sentinel-account, a sentinel public key, and resourceSentinel(binding) = every-hosted--sentinel--<binding> for each D1 (name and id), KV (id), Hyperdrive (id), R2 (name), queue (name, in the producer binding and in the consumer trigger's name and deadLetterQueue) AI Gateway (the json binding's value.id, with value.accountId the account placeholder and value.url https://gateway.ai.cloudflare.com/v1/<account placeholder>/<id placeholder>) and Secrets Store secret (store id and secret name).
installAppBuildOutput (packages/cli/src/lib/app/installBuildOutput.ts) rewrites each Worker's worker.config.json in place for this install (InstallTarget: workerName, organizationId, accountId, gatewayUrl, identityPublicKeys: [install.jwtPublicKey], resources, deletedDurableObjects, helperWorkers):
- Every
d1,kv,hyperdrive,r2orvectorizebinding whose placeholder field (namefor D1, R2 and Vectorize,idfor KV and Hyperdrive) isresourceSentinel(binding)gets the provisioned resource; one the target has none for isINSTALL_INPUTS_MISSING("The build binds D1 database<NAME>, which the deploy didn't provision"; fixdeclare it in everyapp.config.ts's resources). A binding of those kinds that doesn't hold its placeholder was written by hand incloudflare.config.tsand stays for the policy to judge. Ajsonbinding whosevalue.idisresourceSentinel(<its own binding name>)is an AI Gateway's: its whole value becomes the provisioned{ id, accountId, url }(none provisioned:INSTALL_INPUTS_MISSING, "The build binds AI Gateway<NAME>, which the deploy didn't provision"); every otherjsonbinding is the app's own data and is never handed a gateway. Aqueuebinding whosenameisresourceSentinel(binding)gets the provisioned queue's name, and so does everyqueuetrigger'snameanddeadLetterQueuethat holds a queue placeholder (the placeholder carries the binding); a queue the target has none for isINSTALL_INPUTS_MISSING("The build binds Queue<NAME>", "The build consumes Queue<NAME>" or "sends failed messages to Queue<NAME>", fixdeclare it in everyapp.config.ts's resources.queues). Asecrets-store-secretbinding whosesecretNameisresourceSentinel(binding)gets the provisioned{ storeId, secretName }the same way (INSTALL_INPUTS_MISSINGwith "Secrets Store secret<NAME>" and fixdeclare it in everyapp.config.ts's resources.secretsStorewhen there is none). - Every remaining string: the sentinel key list becomes the install's keys; the gateway URL and the account id (first, since they hold the Worker name's placeholder), the organization id and the Worker name are replaced wherever they appear (the Worker's name, its audience, Durable Object and helper service bindings, Workflow names, buckets named after it).
- The app's Worker gets
{ type: "durable-object", state: "deleted" }for each class indeletedDurableObjects; a class both exported by the build and deleted isAPP_POLICY("declare it in everyapp.config.ts, or stop exporting it"). - Any surviving placeholder (or its JSON-encoded form, in a key or a value) is
INSTALL_PLACEHOLDER_LEFT, naming the paths: "Build the Worker from everyAppWorker() in cloudflare.config.ts, and leave its names, keys and ids to the deploy." - A Workflow name over Cloudflare's 64 characters (only known once installed, since names carry the Worker's) is
WORKFLOW_NAME_TOO_LONG, fixshorten its binding name in everyapp.config.ts.
The policy check: everything it refuses
prepareAppBuildOutput (packages/cli/src/lib/app/policy.ts) checks the installed Build Output and throws one APP_POLICY with every problem: The built Worker can't be deployed as an EveryApp app: then one - <problem> line each, then Build it from everyAppWorker() in cloudflare.config.ts. The purpose, from the module comment: cloudflare.config.ts is the app author's code; this keeps it from shipping a Worker the Gateway's model doesn't allow: public on workers.dev or a domain of its own, without its identity keys or in dev mode, bound to another Worker or to resources the deploy didn't provision for it or that aren't named after it, with a binding type the model doesn't know, or giving away or deleting Durable Object data. The Gateway's own deployer (its deploy API and browser installs) runs its own policy (below). Placeholders in angle brackets stand for the actual names.
Build Output as a whole (prepareAppBuildOutput), each its own APP_POLICY:
| Problem | Why |
|---|---|
The app's Build Output isn't a production build. The manifest's build command must run `cf build` (production by default). |
config.json's buildContext.mode isn't production or isPreview is set |
The app's Build Output has no default Worker. Build it from everyAppWorker() in cloudflare.config.ts. |
No workers/default |
the build has a Worker "<name>" that everyapp.config.ts doesn't declare in workers |
Only declared helpers may ship |
the build has no Worker <helper>, which everyapp.config.ts declares; add it to the Vite plugin's auxiliaryWorkers with everyAppAuxiliaryWorker() |
Every declared helper must be built |
appPolicyProblems (the app's Worker):
| Problem | Why |
|---|---|
its name is "<name>", not "<workerName>" |
The registry's name is the only one the Gateway binds |
EVERYAPP_IDENTITY_PUBLIC_KEYS isn't built in |
Must be a text binding: the Worker verifies the identities the Gateway signs |
EVERYAPP_IDENTITY_ISSUER isn't built in |
Same |
EVERYAPP_AUDIENCE isn't the Worker's name ("<workerName>") |
The gateway mints this Worker's identities for its name and nothing else |
privacyProblems (the app's Worker and every helper): reached only through the Gateway, no public URL, domain or route.
| Problem | Why |
|---|---|
workersDev isn't false |
No workers.dev URL |
previewUrls isn't false |
No preview URLs |
it declares domains (apps are served through the gateway) |
No Custom Domain of its own |
it declares routes (apps are served through the gateway) |
A fetch trigger would serve the app around the Gateway |
it declares a trigger of type "<type>" (apps may only declare cron schedules and consumers of their own queues) |
An email trigger could claim the account's Email Routing addresses; a queue trigger is judged by queueTriggerProblems (below) |
EVERYAPP_DEV is set, which only `everyapp dev` may do |
Dev mode never ships |
queueTriggerProblems (the app's Worker and every helper): a queue consumer takes only the app's own queues, the ones this deploy provisioned (resources.queues): one named otherwise could take another app's messages by name. Its dead-letter queue too, since Cloudflare creates a dead-letter queue that doesn't exist, even under --no-provision (seen live), which no deploy would record.
| Problem | Why |
|---|---|
it consumes queue "<name>", which isn't one of the app's queues in resources.queues (<list>) (or …, but an app consumes only its own queues, declared in resources.queues without any) |
The trigger's name must be a provisioned queue's name |
its consumer of queue "<name>" sends failed messages to "<dlq>", which isn't one of the app's queues in resources.queues (<list>) |
The trigger's deadLetterQueue, when set, must be a provisioned queue |
bindingProblems (env): the allowed binding types are text, json, secret, version-metadata, assets, d1, kv, r2, durable-object, workflow, worker, hyperdrive, queue, ai, browser, images, vectorize, send-email, secrets-store-secret, analytics-engine-dataset. Everything else is refused "until the model covers it: most reach account-wide resources by id or name (a dispatch namespace, private network, rate-limit counters, …), or account-wide capabilities the gateway is meant to broker (email)". Workers AI (ai) "is account-wide inference billed to the account, outside the Gateway's budgets, caps and usage tap (its workers-ai provider is the brokered path), and the deploy provisions nothing to check it against: so only under a binding name the manifest declares in resources.ai, never one cloudflare.config.ts adds on its own". Browser Rendering (browser) is the same: "account-wide browser time billed to the account, so only under a name resources.browser declares". Images (images) the same, under resources.images: "transformations bill the account". A send-email binding (Cloudflare's own email sending, which goes around the Gateway's budgets, sender list and log) is allowed only as everyapp.config.ts declares it in resources.sendEmail: under a declared binding name, with exactly the declared allowedSenderAddresses, destinationAddress and allowedDestinationAddresses (lists compared as sets: order and repeats don't matter; a list is never a single address; absent is absent). The upload itself checks no address (an unverified sender or destination uploads, and Cloudflare refuses it only when the Worker sends), so the policy judges the lists against the manifest, which is the operator's declaration. The addresses are accepted as the manifest writes them: they need not be among the installation's senders (everyapp email set --sender), since Cloudflare refuses a from whose domain isn't onboarded to Email Sending in the account at send time anyway; every entry must name its senders (the strict validator requires a non-empty allowedSenderAddresses), so a binding never sends as an address the manifest didn't list, the Gateway's own sender included. An Analytics Engine dataset (analytics-engine-dataset) "only under a binding resources.analyticsEngine declares, and only the dataset it declares (named after the Worker unless it names one): a dataset's name is account-wide". The declared names reach the check as InstallTarget.declared (the manifest's resources), from the deploy and from release alike. A Secrets Store secret (secrets-store-secret) "holds a credential in the account's store: only the store and secret the deploy provisioned for a binding resources.secretsStore declares, under that binding's name" (InstallTarget.resources.secretsStore, by binding: the same store id and secret name).
| Problem | Why |
|---|---|
<NAME> is a <type> binding, which EveryApp apps can't use |
Type not in the list |
<NAME> binds Worker "<worker>"; apps may only bind the gateway's AppGateway and, from the app's own Worker, its helper Workers |
The only allowed service bindings: EVERY_APP_GATEWAY → every-app-gateway export AppGateway, and (app Worker only) its declared helpers |
<NAME> binds another Worker's Durable Object ("<worker>") |
Only the app's own Workers' classes |
<NAME> isn't a D1 database the deploy provisioned |
The id must be one of the provisioned D1 ids |
<NAME> isn't a KV namespace the deploy provisioned |
Same for KV |
<NAME> isn't a Hyperdrive config the deploy provisioned |
"A config holds a database's credentials: only the app's own" |
<NAME> is queue "<name>", which isn't a queue the deploy provisioned (declare it in resources.queues) |
"A queue holds messages: only the app's own, as the deploy provisioned" (by name; one named after the app but not declared is refused too) |
<NAME> is Vectorize index "<name>", which isn't one the deploy provisioned |
"An index holds the vectors of whoever wrote them: only the app's own": the name must be one of resources.vectorize's indexes this deploy provisioned (InstallTarget.resources.vectorize); a hand-written binding to another index, even one named after the Worker, is refused |
<NAME> is bucket "<bucket>", which isn't one of the app's buckets in resources.r2 (<list>) |
When the manifest declares buckets, only those |
<NAME> is bucket "<bucket>"; an app's buckets are named after its Worker (<workerName> or <workerName>--<name>) |
When it declares none, any bucket bound by hand must be named after the Worker (isOwnResourceName: exactly the Worker's name, or it plus --) |
<NAME> binds Workflow "<workflow>" of Worker "<worker>"; an app binds only its own Workflows, named after its Worker (<workerName>--<name>) |
"Workflow names are account-wide and name their instances" |
<NAME> is a Workers AI binding, which everyapp.config.ts doesn't declare; list it in resources.ai |
An ai binding with no resources.ai behind it: account-wide inference the manifest never asked for |
<NAME> is a Workers AI binding, which isn't one of the app's in resources.ai (<list>) |
Only the names resources.ai declares |
<NAME> is a Browser Rendering binding, which everyapp.config.ts doesn't declare; list it in resources.browser |
A browser binding with no resources.browser behind it: account-wide browser time the manifest never asked for |
<NAME> is a Browser Rendering binding, which isn't one of the app's in resources.browser (<list>) |
Only the names resources.browser declares |
<NAME> is an Images binding, which everyapp.config.ts doesn't declare; list it in resources.images |
An images binding with no resources.images behind it: account-wide transformations the manifest never asked for |
<NAME> is an Images binding, which isn't one of the app's in resources.images (<list>) |
Only the names resources.images declares |
<NAME> is a send-email binding that everyapp.config.ts doesn't declare in resources.sendEmail (with (it declares <names>) when it declares others) |
Cloudflare's own email sending only as the manifest declares it, so cloudflare.config.ts can't add it |
<NAME> isn't the send-email binding resources.sendEmail declares: <field> is <built>, not <declared> (one ; -separated clause per differing field; a list reads [<addresses>], sorted, a missing field none) |
Its addresses must be exactly the manifest's |
<NAME> is Secrets Store secret "<secretName>" in store "<storeId>", which isn't the one the deploy provisioned for a binding in resources.secretsStore |
Another store, another secret, or the app's own secret under a name resources.secretsStore doesn't declare: a store secret is a credential |
<NAME> is an Analytics Engine binding, which everyapp.config.ts doesn't declare; list it in resources.analyticsEngine |
An analytics-engine-dataset binding with no resources.analyticsEngine entry behind it |
<NAME> is an Analytics Engine binding, which isn't one of the app's in resources.analyticsEngine (<list>) |
Only the bindings resources.analyticsEngine declares |
<NAME> writes to dataset "<dataset>", not "<declared>", which resources.analyticsEngine declares |
The dataset a declared binding writes to is the entry's dataset, or <workerName>--<binding> without one |
<NAME> writes to dataset "<dataset>", which is named like another app's; a dataset resources.analyticsEngine names starts with "every-" only as <workerName>--<name> |
Every app's default dataset is every-…: a named one never takes another app's |
exportProblems (exports): "Durable Object exports may create, rename or keep classes. Deleting one deletes its data, so only the classes the deploy dropped with --allow-delete-durable-object-data may be; moving one's data to another Worker, or taking another Worker's, never. None may be backed by a container, which the deploy doesn't build, own or bill to an app. Workflows must be named after the app's Worker."
| Problem |
|---|
Workflow <export> is named "<workflow>"; an app's Workflows are named after its Worker (<workerName>--<name>) |
export <name> is a <type>, which EveryApp apps can't declare (anything but worker, workflow, durable-object) |
Durable Object class <name> is backed by a container, which EveryApp apps can't run |
Durable Object class <name> is marked deleted, which deletes its data; remove it from everyapp.config.ts and deploy with --allow-delete-durable-object-data instead |
Durable Object class <name> would move its data to Worker "<transferredTo>" (state: "transferred") |
Durable Object class <name> would take the data of Worker "<transferFrom>"'s class (state: "expecting-transfer") |
Durable Object class <name> has state "<state>", which everyapp doesn't deploy (any state but created, renamed, deleted, transferred, expecting-transfer) |
helperPolicyProblems (each helper, prefixed <helperName>: ): its name is "<name>", not "<helperName>", then the privacy problems, the binding problems with only the Gateway binding allowed as a service binding (a helper never binds the app or another helper), and the export problems with no tombstones ("No tombstones on a helper yet: its classes stay while it does").
After the check passes, each Worker's source maps are listed as sourcemap modules (cf uploads source maps only as listed modules), the config is validated against cf's schema (validateWorkerConfig, packages/cli/src/lib/gateway/patchBuildOutput.ts) and written back. The function returns the Workflows each Worker defines (its workflow exports and workflow bindings to itself); It also returns the R2 buckets the Workers bind by name, declared in the manifest (resources.r2) or named by hand in cloudflare.config.ts; ensureR2Buckets (packages/cli/src/lib/app/buckets.ts) makes each one that doesn't exist (cf's prebuilt deploy creates none, and the upload would fail with [10085] R2 bucket '<name>' not found), saying Created R2 bucket "<name>". as progress on app.build; one that exists is bound as it is, its objects kept, and buckets are never recorded as the app's or purged. An account that never enabled R2 stops here with R2_NOT_ENABLED. assertOwnWorkflows (packages/cli/src/lib/app/workflows.ts) then asks Cloudflare for each name and refuses one another Worker runs: WORKFLOW_NAME_TAKEN, "deploying would take it over" (cf's update reassigns a name another Worker holds). app.build ends ok with data.release and data.workflows.
app.schema: the schema-drift check
checkSchemaDrift (packages/cli/src/commands/deploy/steps/checkSchemaDrift.ts) runs after the build, before the database or the Worker changes, for a drizzle app with D1 bindings or migrations.dialect: "postgresql". It runs the app's own drizzle-kit generate with a two-line config that extends the app's and points out at a copy of the migrations under .everyapp/schema-check-* (drizzle-kit 0.31 refuses --out next to --config, and resolves out against the working directory). A new .sql file in the copy is the verdict: SCHEMA_DRIFT, "The schema has changes no migration covers. drizzle-kit would write a new migration for them. Nothing was migrated or uploaded.", rerun pnpm db:generate "then run this again". A run that fails without writing one (a schema that doesn't load, a rename drizzle-kit would ask about at a terminal; made an exit code by a --require hook since drizzle-kit exits 0 on its own errors) is SCHEMA_CHECK_FAILED, rerun pnpm db:generate "in a terminal; it asks about renamed columns and tables". The scratch copy is removed in finally and on interrupt.
Two cases are not checked and emit no app.schema event at all (the function returns before anything): migrations.engine other than drizzle or prisma ("d1-sql"), and an app with no D1 bindings that isn't Postgres. drizzle-kit gets 120 seconds (GENERATE_TIMEOUT_MS); past that the run is SCHEMA_CHECK_FAILED with the reason no answer in 120 s when drizzle-kit printed nothing (else its first Error line, or its last). Skips, each a warning SCHEMA_CHECK_SKIPPED on app.schema ("Can't check the schema against the migrations: <why>"): drizzle-kit not installed (fix add drizzle-kit to devDependencies), no drizzle.config.ts/.js (or none at migrations.config; "An app that writes its SQL by hand sets migrations.engine to "d1-sql""). migrations.engine: "prisma" is never checked (Prisma compares only against a shadow database): app.schema skip, "Schema not checked against migrations (Prisma needs a shadow database; --verbose shows how)". A release skips the step with a progress line: its publisher checked when it packed. A passing check is app.schema ok, "The schema matches the migrations."
Replacing a Worker changed outside everyapp
Before the database changes, assertReplaceable (packages/cli/src/lib/cf/deploy.ts) runs for the app's Worker and each helper: the version serving must be one everyapp recorded (plan.versionId from the registry, or the ledger's version_id). cf's strict mode refuses to replace a Worker last changed in the dashboard or through the API unless a person confirms, and that question would come after migrating; so it is asked now. Unattended: action_required rerun_interactive, "<app> was changed outside everyapp; confirm in a terminal … Nothing changed.", data.workerName, data.versionId, data.needsTerminal: true, data.rerun (this command) with data.rerunNote "in a terminal": an agent hands data.rerun to a person as it is and never adds --json back (with it the run stops here again). At a terminal: Replace the version that's serving? with "Migrations run first." and "Cloudflare may ask to confirm again."; No is CANCELLED. --yes never answers this question.
app.migrations: migrations and restore points
lease.check() first (LEASE_LOST), then migrate (deployApp.ts). The database changes only once nothing else can stop the upload; a failed migration leaves the version that is serving untouched.
D1 (migrateRemoteD1, packages/cli/src/lib/migrations/remote.ts), on the migration binding (migrations.binding, else the first D1 binding) from migrations.dir (default drizzle; drizzle-kit's <tag>/migration.sql layout and flat *.sql both read):
cf d1 migrations list: nothing pending ends the stepok, "The app's database is up to date."- Unless the database had none of the migrations yet (
fresh: nothing to go back to, and any old restore point is forgotten), a Time Travel bookmark (cf d1 time-travel get-bookmark <id>) is recorded in_everyapp_restore_points(packages/cli/src/lib/gateway/restorePoints.ts) under targetapp:<workerName>with the pending migrations, the deployer's actor and the time. cf d1 migrations apply, which keeps its ownd1_migrationstable.
app.migrations ok says Applied <names>. Restore point saved; to undo: pnpm everyapp db restore (for a release: pnpm everyapp db restore <appId>, since the release isn't deployed from the app's directory); data: applied, fresh, restoreCommand, rollbackCommand. rollbackCommand is cf's own npx cf@<pinned> d1 time-travel restore <id> --bookmark <bookmark> --force (prefixed with the account id; restoreCommand in packages/cli/src/lib/cf/d1.ts), for scripts. A failure is MIGRATION_FAILED: "Database migration failed … Nothing was uploaded; the version that's serving keeps running. To roll the database back to before this run: pnpm everyapp db restore", data.fix correct the migration, data.rollbackCommand. Any later failure in the run (the upload, the rebind) carries the same restore point (afterMigration, packages/cli/src/lib/errors.ts). The Gateway's own database is restored with pnpm everyapp db restore --gateway.
Postgres (migrations.dialect: "postgresql"): the app's own drizzle-kit migrate (packages/cli/src/lib/migrations/postgres.ts) or, with migrations.engine: "prisma", prisma migrate deploy (packages/cli/src/lib/migrations/prisma.ts), with migrations.config when set, against POSTGRES_DATABASE_URL from .everyapp/secrets.env. The URL reaches the CLI only in its environment, and its output comes back with the URL and password replaced (packages/cli/src/lib/migrations/localCli.ts, 10 minutes at most). There is no restore point: the database's own backups are the way back. A missing CLI is DRIZZLE_KIT_MISSING or PRISMA_MISSING; a failure MIGRATION_FAILED with the first error (or P\d{4}) line. app.migrations ok: Migrated the app's Postgres database (drizzle-kit migrate), data.dialect, data.engine, and for Prisma data.applied.
app.helper and app.upload: cf deploy --prebuilt
cfDeploy (packages/cli/src/lib/cf/deploy.ts) runs cf deploy --prebuilt --no-provision --mode production --message "everyapp <cli version> <appId>[ <helper>]" [--worker <helper>] --secrets-file <0600 temp file> in the Build Output root, 15 minutes at most. --no-provision (every upload, the Gateway's too): cf otherwise creates a missing D1 database, KV namespace, R2 bucket or queue that a binding names, which no deploy would record as the app's; with it, a binding to something missing is refused before the upload (Queue "<name>" does not exist. …). The secrets file holds GATEWAY_URL and EVERY_APP_ORG_ID (versionSecrets, deployApp.ts), so they ship in the same Worker version as the code ("A helper calls the gateway too, and without EVERY_APP_ORG_ID it would name the app's own organization id, which the gateway refuses"). Helper Workers go first (app.helper per helper, data.name, data.workerName): the app's Worker binds them, so each must exist, with any new class, before the version that uses it. lease.check() runs before each upload. cf's output stays in memory unless --verbose at a terminal attaches it (then cf asks its own questions).
The uploaded version is recorded in the ledger as soon as it is known (recordUploadedVersion, onUploaded), also on the way out of a Ctrl+C or a cf failure after the upload, "so a retry must find this upload was everyapp's, however this run ends". Failures:
| Code / action | When |
|---|---|
rerun_interactive (action, data.needsTerminal: true, data.rerunNote "in a terminal, and confirm cf's prompt") |
cf's strict_conflict: the Worker was changed outside everyapp and cf is piped; at a terminal cf is rerun attached to ask instead. An agent hands data.rerun to a person as it is, never with --json added back |
register_workers_subdomain (action, with the dashboard URL) |
cf's subdomain_missing: "Cloudflare needs one before an account's first Worker. Apps never use it." |
upgrade_workers_paid (action, https://dash.cloudflare.com/<account>/workers/plans) |
cf's free_plan: "EveryApp needs Workers Paid ($5 a month, then usage)" |
DEPLOY_NOT_UPLOADED |
cf exited 0 but the serving version didn't change (a prompt answered no) |
VERSION_UNKNOWN |
Cloudflare reports no version after the upload |
QUEUE_HANDLER_MISSING |
The version went live, then Cloudflare refused the Worker's queue consumers ([code: 11001], "Queue handler is missing"): its bundle exports no queue() handler. The version is recorded; the next deploy with the handler publishes the consumers |
app.upload ok: Uploaded version <versionId>., data.versionId. cf creates or updates the Workflows the Worker defines as it deploys it, and publishes its queue consumers from its queue triggers once the version is live (one consumer per queue and Worker, its settings updated in place on a redeploy; checked live on 2026-10-24), so the deploy makes no consumer of its own.
app.workflows, app.email and the registry record
- Each Workflow dropped with
--allow-delete-workflowsis deleted now, before the registry records the new manifest ("so a retry after a failed delete deletes them still"):app.workflowsok,Deleted the Workflow <binding> (<name>) with its instances. app.email(when there are routes to make or remove):lease.check(), thenapplyEmailRoutes;okwithdata.addresses,data.removed,data.enabled, anddata.replyHintwhen senders are missing for a routed domain. An app that declares inbound mail with no stored address:app.emailskip, "<app>receives no mail yet.", anddone.data.nextgetspnpm everyapp email set --inbound <address>. An app whose manifest declares sending (capabilities.emailwithaccess: "write":emailDeclarations,packages/cli/src/lib/app/emailRoutes.ts) on a Gateway whosedeploymentModeisn'thosted(sendersSettable: the installation's senders are its own to set) with no sender stored:done.data.nextalso gets{ command: "pnpm everyapp email set --sender '*@<domain>'", note: "lets <app> send as your domain" }; until a sender is set, the app sends as the Gateway's sender. Setting up the Gateway's mail step by step:packages/cli/skills/everyapp/setup/email.md, in an app.claude/skills/everyapp/setup/email.md.- A helper Worker the manifest dropped keeps running: warning
HELPER_WORKER_KEPT("the deploy never deletes a Worker"; fixdelete that Worker in the Cloudflare dashboard). markDeployed(registry.ts) writesmanifest,version_id,deployed_at,deployed_by_actorand the status (pending→binding, any other kept: "an admin switching the app off meanwhile keeps it off"), only over the status it was read with, three attempts; a row that keeps changing isAPP_CHANGING("The upload is kept.", retryable); a row that is gone isAPP_NOT_FOUND. The manifest's route policy goes live with this write.- After the upload is recorded, the manifest's settings are checked against the Gateway's names and the Worker's own (
warnIfNeedsSetup,deployApp.ts): an app with an unmet required setting gets warningAPP_NEEDS_SETUPon stepapp.secrets("<app>won't open until<NAMES>is set", the first unmet setting'swhyunder it;data.setup) and onenextentry with the command that opens it,pnpm everyapp configure <app>(note "asks for each;<app>opens once they are set"). Until then the app shows "Needs setup" and its tools stay out of/mcp(the secrets page).
The app lease is released.
app.bind: the Gateway rebind and upgrade
bindIfNeeded (packages/cli/src/commands/appGateway.ts) returns at once when the install row's liveAppWorkers already lists the Worker and the hostname isn't new: a redeploy of an app the Gateway already serves never touches the Gateway. Otherwise app.bind starts (Binding APP__<workerName> on the gateway[ and publishing its hostname]...) and reconcileGateway (packages/cli/src/lib/gateway/reconcile.ts, force: true) redeploys the Gateway with a fresh set of APP__<worker> service bindings and Custom Domains:
- The release is the one this checkout builds (
checkoutGateway,packages/cli/src/lib/gateway/checkoutRelease.ts, throughgatewayForinappGateway.ts; every Gateway deploy comes from the checkout everyapp runs in):openCheckoutGatewayRelease, the SDK's build then the Gateway'sbuild:release(gateway.releasestart: "Building the Gateway from this checkout (usually 1–2m)"; a failure isGATEWAY_BUILD_FAILED), cached undernode_modules/.cache/everyapp/<tree>.tar.gzby the tree hash ofapps/every-app-gateway,packages/sdk,packages/perimeter,packages/toolbusandpnpm-workspace.yaml(every file git tracks or would, as on disk; three builds kept).gateway.releaseoksays which ("Using the Gateway X built from this checkout before (…)", "Built the Gateway X from this checkout."). A release whoseminCliis newer than the CLI:CLI_TOO_OLD(refuseNewerThanCli,packages/cli/src/lib/gateway/release.ts, called byreconcileGatewayinpackages/cli/src/lib/gateway/reconcile.tsonce the release is open, beforegateway.lease). - When the checkout's version differs from the install row's
releaseVersion,gateway.upgradestarts (Upgrading the Gateway <from> → <to>...): deploying an app upgrades the Gateway to this checkout when it has to rebind. gateway.lease(purposegateway.reconcile),LEASE_HELDafter the wait, rewrapped bybindIfNeeded: "This app's upload is kept; deploying again binds it and makes it live.", rerun "once the other deploy finishes".applyGateway(packages/cli/src/lib/gateway/apply.ts), under the lease: the downgrade guard again; when the release isn't the live one (by SHA-256 or tree hash), the patched Build Output is validated, the registry's hosts are preflighted ongateway.domains, the Gateway Worker must serve a recorded version (assertReplaceable, "Replace it with X?"), andgateway.migrationsmigrates the Gateway's D1 with a bookmark (restore:pnpm everyapp db restore --gateway). Then the desired state is read from the registry (apps whose Worker isn't the registered one get warningAPP_NOT_BOUND, whose Worker is missingAPP_WORKER_MISSING; the Gateway won't route to them), every new hostname is preflighted (DNS_RECORD_CONFLICT,--force-dns), andgateway.deployuploads once withcf deploy --prebuiltfrom a private patched copy of the release (Uploading Gateway <version> with <n> apps,data.appWorkers,data.domains). The install row records the version as soon as it is known and the hostnames only once cf published them.app.bindok:The gateway binds APP__<workerName> (gateway version <id>).,data.versionId,data.newHosts.
app.live: the live wait
waitForLive (packages/cli/src/commands/deploy/steps/waitForLive.ts) runs unless --no-wait or the hostname is outside the base. The Gateway makes a binding app active itself, on the first request that reaches it over the service binding; this step sends that request (GET https://<hostname>/, probeHttps, resolved at the zone's nameservers so a stale local resolver can't hold it back) every 3 seconds, reads the app's row after each, and emits progress every 15 seconds ("Waiting for the certificate for <hostname> (usually under 5m)" until something answers, then "Waiting for <app> to go live"). The request is the trigger, D1 is the truth: no HTTP status says an app is live by itself. Two more triggers make the same probe and the same transition (activateBindingApps and activateDeployingApps, apps/every-app-gateway/src/server/app-activation.ts): the Gateway's per-minute cron sweeps every binding app of the install (at most 50 a run), and the Gateway's home page and Admin → Apps probe each binding app they list (the launcher reads its list again every 5 seconds while one is deploying). So an uploaded app is live within a minute whether or not anyone visits. Behind Cloudflare Access this step's request never reaches the Gateway: Access answers it with a redirect to its login. From that answer on the step sends nothing more and only reads the row (D1, through the Cloudflare API, which Access doesn't stand in front of), with the progress line "Cloudflare Access answers before the Gateway; waiting for the Gateway to make <app> live (it looks every minute)" (data.access: true), until the sweep makes it active. The one app.live event of the transition says which trigger made it (request, list or scheduled; events).
| Outcome | Event |
|---|---|
Row says active |
app.live ok, <app> is live at https://<hostname>., data.hostname, data.url, data.waited (certificate or live when it waited). Warning LOCAL_DNS_STALE when this computer's resolver still can't resolve the name |
Row says disabled |
Warning APP_DISABLED ("switched off in Admin → Apps"); the run ends not live |
| Cloudflare Access answers before the Gateway | No event of its own: the wait goes on reading the row (the progress line above) and ends like any other, active within a minute in the normal case |
| 10 minutes behind Access | Warning ACCESS_IN_FRONT: "Cloudflare Access answers before the Gateway, and the Gateway hasn't made <app> live after …; The deploy stalled: …", data.rerun pnpm everyapp status "then deploy again"; app.live skip |
| 10 minutes with no answer at all | Warning CERT_PENDING ("No certificate for <hostname> after …; It usually comes within 5 minutes"; fix: check the zone's CAA records allow Cloudflare's certificate authorities); app.live skip |
| 10 minutes while the Gateway answers | APP_NOT_LIVE: "<app> isn't live after …: the Gateway can't reach its Worker yet. <app> stays uploaded and bound.", retryable, rerun pnpm everyapp status "then deploy again" |
Row gone or deleted during the wait |
APP_NOT_FOUND: "was deleted during this run" |
Without the wait, or when it ended early, app.live is ok for an app whose status was already active (a redeploy), else skip with <app> isn't live yet: https://<hostname>. and data.reason: certificate (--no-wait with a new hostname), first-visit (--no-wait, hostname already published), outside-base (never routed; also warning APP_HOST_OUTSIDE_BASE: "The Gateway only serves names directly under <base>").
app.access: the manifest's public routes behind Cloudflare Access
After the live wait (and before APP_HOST_OUTSIDE_BASE), deployApp calls syncAppsAccessAfter (packages/cli/src/lib/access/sync.ts) with the manifest's public surface ({ publicRoutes, mcp }: whether it has public routes, and whether it sets mcp), so an app's public routes, and its /mcp where asked for, are reachable without an Access login as soon as the app is. It never fails the run: everything that goes wrong inside it is a warning. It lists the account's Access applications (listAccessApplications, packages/cli/src/lib/cf/access.ts) and returns silently when none is a self_hosted application with a public destination on *.<appBaseDomain> (Access isn't in front; findAccessApplications, packages/cli/src/lib/access/plan.ts, finds the applications by what they cover, not by name). Otherwise it takes the registry lease access (ACCESS_LEASE_ID, purpose access.sync; access on holds the same lease while it writes the applications, turnAccessOn in packages/cli/src/commands/access/index.ts): a wait is progress on gateway.lease ("Waiting for an update of Cloudflare Access to finish (…)"), lasts up to 60 seconds (SYNC_LEASE_WAIT_MS) and ends with gateway.lease ok "The other Cloudflare Access update finished."; past it, LEASE_HELD becomes the ACCESS_SYNC_FAILED warning below. Under the lease it reads the applications again and every served app (readPublicSurfaces: apps rows with status binding, active or disabled and a hostname, each with the public[].path list and the mcp of the manifest it was last deployed from; a manifest that doesn't parse counts as neither), plans the apps application's destinations (appsDestinations: the wildcard *.<base> with nothing let through, then one destination per app with public routes or mcp, by host: /mcp and /mcp/* first only where the manifest sets mcp, then its routes as Access path patterns in manifest order, each once, up to 25; with Managed OAuth on the Gateway host's application no /mcp is let through anywhere, and an app with mcp alone gets no destination), keeps every destination that isn't EveryApp's as found (and the Gateway host's own when one application covers both), and writes the application back (updateAccessApplication) only when the result differs from what Access has (sameDestinations: lowercased, trailing slashes dropped, overrides sorted). The result is step app.access with data: { appId, applicationId, changed, removed }: ok Cloudflare Access lets <app>'s <public routes | /mcp | public routes and /mcp> through. (a person sees that line, packages/cli/src/lib/output/events.ts), or skip Cloudflare Access already matched <app>'s <…>. (shown to nobody); the words follow what the manifest opens (surfaceWords), and a delete says no longer lets <app>'s <…> through.
Warnings:
ACCESS_NOT_CHECKEDonapp.accesswhen the applications couldn't be listed. For a login or token without the Access permissions (cf'sauthrefusal, whichlib/cf/access.tsturns intoACCESS_PERMISSION_MISSING, "This Cloudflare login can't manage Access") the warning is "This Cloudflare login can't read Cloudflare Access, so<app>wasn't checked against it", said only when the app has public routes or setsmcp, the things a sync would change for it (a CI token deploying an app with neither gets nothing); for any other refusal "Couldn't check Cloudflare Access for<app>" with the error's first line, always said; each followed by "If the Gateway is behind Access,<app>'s<public routes | /mcp | public routes and /mcp>need a login there until it is updated."data:appId,rerunpnpm everyapp access on,rerunNote"with a login that can manage Access, if the Gateway is behind it" (permissions) or "if the Gateway is behind Access".ACCESS_SYNC_FAILEDonapp.accesswhen the sync itself failed (the lease wait included): "Couldn't update Cloudflare Access for<app>'s<public routes | /mcp | public routes and /mcp>", the error's first line, "Until it is updated, they need an Access login.";data:appId,rerunpnpm everyapp access on.- The plan's leftovers, on step
access.routes(emitPlanWarnings), without arerun:ACCESS_ROUTE_NOT_EXPRESSIBLE("A public route stays behind Access:<app> <path>" or "<n>public routes stay behind Access: …", then "Access path patterns take one * per segment, so these have no equivalent. People signed in to Access still reach them.";data.routes),ACCESS_TOO_MANY_PUBLIC_ROUTES(the same first line, then "Access lets through at most 25 paths per host (two of them for /mcp where the manifest sets mcp). Merge routes under one prefix, or keep these behind the login.";data.routes),ACCESS_APP_OUTSIDE_BASE(an app with public routes whose host isn't one label below the base gets no destination: "<app>isn't under the apps' domain, so Access isn't set up for it.";data.apps),ACCESS_FOREIGN_DESTINATION("The Access application "<name>" also covers<uris>", "That destination isn't EveryApp's and stays as it is. access off won't delete this application.";data.application,data.uris).
delete runs the same with removing: true (below). Putting the Gateway behind Access is pnpm everyapp access on (step by step: packages/cli/skills/everyapp/setup/access.md, in an app .claude/skills/everyapp/setup/access.md); patterns, destinations and everyapp access on|off|show are on the Access page.
What the end says: facts and done.data
For a person (packages/cli/src/lib/output/events.ts): the first line Deploying <app> to <gatewayHost> (<organization>), ticked lines per step (Installed dependencies, Built or Release checked, Schema matches the migrations, for app.migrations Database up to date, Database created (a fresh database, with <n> migrations as detail) or Database migrated (the applied names as detail and "Restore point saved. To undo: <command>" as a note; a drizzle-kit Postgres run, which doesn't say what it applied, gets the bare Database migrated), Uploaded helper Worker <name>, Uploaded, Added to the Gateway, Cloudflare Access lets <app>'s public routes through when that changed, Certificate issued), and facts at the end: Live with the URL (or URL and Status: Goes live on its first visit[, once its certificate comes (a few minutes)], or Live: Not routed), Public with Open to the internet without sign-in: GET /hooks/*, POST /collect (the first six, then … and <n> more (everyapp.config.ts public); listPublicRoutes, packages/cli/src/lib/app/publicRoutes.ts) on every deploy of an app with public routes, and on a first deploy Who can open: Private to you, owners and admins · change it at <gatewayUrl>/admin/apps, Private to owners and admins (deployer not a member), Everyone in <organization> (access: "everyone"), or Members of every organization (shared app); with no owner yet, "The Gateway has no owner yet; its first owner gets the app (pnpm everyapp setup)." Under gateway.upgrade the Gateway's steps are indented under Gateway <from> → <to>.
done.data (liveDone, appGateway.ts, plus deployDone, index.ts):
| Field | Value |
|---|---|
url |
https://<hostname> |
workerName |
every-<orgKey>-<appId> |
versionId |
The uploaded Worker version |
live |
true when the registry says active (the wait saw it, or it was active before this redeploy and the hostname is inside the base) |
organization |
The organization's name |
publicRoutes |
What the manifest opens to anyone on the internet, [{ path, methods }] each; [] when nothing; mcp beside it: { by, why } when the app's host answers /mcp (by the Gateway's ToolBus or the app's own server), else null |
next |
Only when something is left to do: [{ command, note }]. Three entries exist: pnpm everyapp email set --inbound <address> (note "routes mail to <app>": inbound mail declared, no address stored), pnpm everyapp email set --sender '*@<domain>' (note "lets <app> send as your domain": sending declared, Gateway not hosted, no sender stored), and pnpm everyapp configure <id> (note "asks for each; <app> opens once they are set": a required setting unmet, beside the APP_NEEDS_SETUP warning) |
Idempotence: what is safe to rerun
Every step looks before it acts, so the same command runs again after any stop:
- Registration of an existing row only updates name and description; a first deploy that stopped is still
pendingand still the first (firstDeploy). - Resources are recorded as the app's before they are created, and an upload's version as soon as cf reports it, so a retry finds both; a claim whose create never recorded an id is finished by the next run ("Claimed, then created, but the id never recorded: it's this one").
- The deploy lease is freed on Ctrl+C, on release, and expires 3 minutes after a dead holder's last heartbeat; the next run waits up to 5 minutes for it.
- Migrations run before the upload, with a restore point; a failed upload leaves the migrated database and says how to put it back.
- Dropped Workflows are deleted before the registry records the new manifest, so a retry deletes them still.
- The rebind compares the install row with the registry and uploads the Gateway only when something differs (or
force); an app the Gateway already binds and serves never redeploys it. - Nothing about an install is kept on the machine; a second machine needs only the login and the CLI.
What is never done twice by itself: deleting a Worker (never done by deploy at all), replacing a Worker changed outside everyapp (asked each time), replacing a DNS record (--force-dns each time).
Redeploys: what changes and what is kept
| Kept | Changes |
|---|---|
| The Worker name, hostname and resource names (assigned once; a tombstone keeps them) | The Worker's code and worker.config.json (bindings, cron triggers, Durable Object classes) |
Status, scope (a redeploy asks for none, so a shared row stays shared) and access (user_app_access, is_default) |
The stored manifest (its route policy goes live with the record), version_id, deployed_by_actor |
| D1, KV, Hyperdrive and Vectorize data: the same recorded resources are bound again | The schema, by the pending migrations (restore point first) |
Durable Object data, unless a class is removed with --allow-delete-durable-object-data |
Workflows: created or updated by cf, deleted only with --allow-delete-workflows |
| The Gateway's binding and the hostname's Custom Domain and certificate (no rebind when the row already lists them) | The Gateway itself, only when a rebind is needed and the checkout is newer |
The app's own secrets, set on the live Worker with everyapp configure <app> or everyapp secret set (packages/cli/src/lib/cf/secrets.ts, the secrets page): production builds don't declare them, "and later deploys keep them" (secrets in packages/sdk/src/cloudflare/worker.ts); the deploy's secrets file carries only GATEWAY_URL and EVERY_APP_ORG_ID |
The installation's mail routes, re-planned from the row's inbound list and the manifest's email declaration |
| The Gateway takes the app's agent tools itself, on first use and whenever the version changes | Behind Cloudflare Access, what Access lets through on the app's host: the manifest's public routes, re-synced by app.access |
Release installs: install <source>
pnpm everyapp install <source> installs an app release with no checkout of the app (fetchAppRelease, packages/cli/src/lib/app/release.ts; packages/cli/src/lib/release/fetch.ts, packages/cli/src/lib/release/github.ts):
sourceis a github.com repository URL (its latest release;…/releases/tag/<tag>or…/releases/latest: that one), any other HTTPS URL (a tarball to download), or a local file. A repository that publishes noeveryapp-release.tar.gzisNO_RELEASE("publishes no everyapp release … Its GitHub releases have no everyapp-release.tar.gz"; fixclone it, then run pnpm install in it and this checkout's pnpm everyapp deploy from its directory): the CLI never builds a repository from its URL. A path that isn't there isRELEASE_NOT_FOUND.- Downloads are anonymous (no token, cookie or identifying header, so a private repository's releases answer 404), follow redirects by hand and only to HTTPS (5 at most), refuse a body over 200 MB (
RELEASE_TOO_LARGE) or over 5 minutes (RELEASE_DOWNLOAD_FAILED, retryable), and land in the CLI's private temp dir. The tag comes from the/releases/download/<tag>/URL GitHub redirects to. - A release (
packages/cli/src/lib/gateway/release.tsextractRelease) holds onlyrelease.json,.cloudflare(the Build Outputv0, placeholders and all) andmigrations(the manifest's migrations directory, when there is one);release.jsonis schema 1,kind: "app", withappId,version,gitSha,builtAt,cfVersion,cliVersion,sdkVersion,buildOutputVersion,sha256,migrations(the drizzle journal),minCli,minSdkand the evaluatedmanifest(itsmigrations.dirrewritten tomigrations). Checks: the kind (RELEASE_INVALID), nothing else at the top level (RELEASE_INVALID), a Build Output this CLI deploys (v0, elseRELEASE_UNSUPPORTED), the SHA-256 of every file butrelease.json(hashReleaseTree: each path and its content hash, in path order; a mismatch isRELEASE_CORRUPT),minClinot newer than the CLI (CLI_TOO_OLD), a manifest the strict parser takes whoseidis the release'sappIdand whosemigrations.dirismigrations(RELEASE_INVALID). - The pipeline is then the same with the extracted release as the Build Output root and its manifest as the app's: nothing is installed or built (
app.buildok: "Installed the release and checked its Worker."; a person seesRelease checked), the install rewrite and the policy check run on it all the same, and the schema check is left to the publisher's build. The app's.everyapp/secrets.envis read from the current directory.app.deploysaysDeploying <app> <version> from github.com/<owner>/<repo> (release <tag>) to …and carriesdata.release. - What a release deploy can't do: Postgres migrations (
RELEASE_MIGRATIONS_UNSUPPORTED: drizzle-kit and Prisma need the app's source and config; a Prisma app can't even be packed). The migration restore command ispnpm everyapp db restore <appId>. - Publisher trust (
docs/security-model.md, "Trust Zones"): the SHA-256 proves only that the tarball is whole, not who built it; whoever deploys a release trusts its publisher as they would trust the repository's code.
release: packing a release
A repository publishes one on every push with the Release workflow pnpm pack-for-app <app> --workflow installs from an EveryApp checkout (scripts/pack-for-app/release.yml; the runbook is docs/runbooks/app-releases.md, which also covers how an app's clone builds on its own: the CLI and the SDK pinned to one published version, or vendored as file: tarballs). bundleApp (packages/cli/src/commands/release/bundleApp.ts) needs no Gateway and no Cloudflare login: app.install, app.build (cf build, then the policy check with the build's own placeholders, sentinelDeployTarget(manifest), and the helper names derived from the sentinel Worker name), app.schema, then packAppRelease (packages/cli/src/lib/app/release.ts):
- The Build Output must be
v0alone in.cloudflare/outputand a production build, elseRELEASE_PACK_FAILED. - The staged tree is the Build Output, the migrations directory (drizzle, or
d1-sql) asmigrations/, andrelease.json(versionfrompackage.json,0.0.0when it has none or can't be read;gitShathe checkout'sHEADwith-dirtywhengit status --porcelainshows changes under the app,unknownoutside a git checkout;cfVersionfrom the app's cf,unknownwhen it can't be resolved (bundleApp.ts);sdkVersionthe app's@every-app/sdkversion,nullwithout it (appSdkVersion,release.ts);minCliandminSdk0.3.0). macOS litter (.DS_Store,._*) is never staged;tarruns withCOPYFILE_DISABLE=1. RELEASE_LOCAL_STATErefuses to pack.dev.vars*,.env*,*.pem,.cloudflare/state,.cloudflare/types,.wrangler/or.everyapp/("Refusing to pack local state or secrets into the release").- A Prisma app is refused before staging:
RELEASE_MIGRATIONS_UNSUPPORTED("migrates with Prisma, which a release can't carry").
app.release ok: Packed <app> <version> (<sha256 prefix>) into <path>., data: tarball, shown, appId, version, sha256; a person sees the facts Release <path> and SHA-256 <the full hash>. done.data adds next: { command: "pnpm everyapp install ./everyapp-release.tar.gz", note: "installs it on your Gateway" }, then { command: "pnpm everyapp install <repository url>", note: "installs it on any Gateway, once the tarball is attached to a GitHub release of the app's repository" } (what the command's own description, command.ts, says).
The Gateway's deploy API
The Gateway has a deploy pipeline of its own (apps/every-app-gateway/src/server/deploy/), reached two ways: its deploy API, with a deploy key, and the browser install below, with a session. No CLI command calls either: deploy writes the registry and uploads with cf as above, and the CLI's deploy-key path was removed on 2026-10-24 (docs/security-model.md, "Hosted Deploy API", names the commit it is restorable from).
The API's routes (routes.ts) exist only while platformPolicy(env).thirdPartyDeploys holds (apps/every-app-gateway/src/server/platform-policy.ts: a hosted Gateway whose THIRD_PARTY_DEPLOYS is exactly on; otherwise the paths are the control plane's 404s): GET /api/v1/deploy-key (who the key is: organizationId, userEmail, role, appRowId), POST /api/v1/orgs/<orgId>/apps (JSON { appId, manifest }: the manifest is checked against the third-party policy, manifestProblems below, and the app registered by registerApp, run.ts, through registerDispatchApp in registry.ts: tier dispatch, scope org, Worker every-<orgKey>-<appId>, D1 <worker>--<binding>, hostname <appId>-<orgKey>.<THIRD_PARTY_DOMAIN> or none while that domain is unset; a THIRD_PARTY_DOMAIN on, under or above the Gateway's APP_BASE_DOMAIN is refused as DEPLOYER_NOT_CONFIGURED, 503, "third-party apps need a registrable domain of their own", because the Gateway's session cookie is scoped to its domain), POST /api/v1/orgs/<orgId>/apps/<appId>/deploys (one multipart body, the parts HOSTED_DEPLOY_PARTS names in packages/perimeter/src/manifest/deploy.ts, read by bundle.ts) and GET /api/v1/orgs/<orgId>/deploys/<jobId> (the job: { id, status: "running" | "succeeded" | "failed", error: { code, message } | null, app }; the deploy runs in the request that starts it, which answers the finished job when the deploy took under 20 seconds, else running to poll; a job still running 5 minutes after it was created is answered as failed with DEPLOY_ABANDONED, below). Every call carries a deploy key as the bearer (authenticateDeployKey, auth.ts: a personal access token with the deploy scope and nothing else, of a live owner or admin of the organization it is bound to, checked on every request; no cookie is read). The job records deployed_by_actor as deploy-key:<token id> (routes.ts, pipeline.ts).
The pipeline (run.ts startDeploy, pipeline.ts runDeploy), the same for the API and a browser install: every refusal first, then its D1 databases through the same resource ledger rules, its static assets, the Worker's metadata with every binding rewritten from the manifest and the row (never from the bundle), its D1 migrations (statements split on --> statement-breakpoint, each file in one transaction, recorded in d1_migrations), the upload (into the dispatch namespace on a hosted Gateway; a self-hosted browser install uploads a private account Worker through the Cloudflare REST API, cfRestDeployer.ts), a read-back of the bindings, then the registry record (pending → binding; the Gateway activates it on the first request). Full detail: docs/security-model.md, "Hosted Deploy API".
Codes the routes answer (JSON { "error": "<code>", "message": "<sentence>" } with the code's HTTP status, DeployError in errors.ts; anything else thrown is INTERNAL_ERROR) or the job records, which a browser install raises the same way: from authenticateDeployKey (auth.ts), in order, DEPLOY_KEY_REQUIRED (401: no bearer), DEPLOY_KEY_INVALID (401: "The deploy key is unknown, revoked or expired. Create one in Admin → Tokens (Deploy key)."), DEPLOY_SCOPE_REQUIRED (403: the token isn't a deploy key, a scope beside deploy on the row), NOT_A_MEMBER (401: "The deploy key's owner is no longer a member of its organization, or is deactivated."), DEPLOY_FORBIDDEN (403: not an owner or admin), ORGANIZATION_SUSPENDED (403); from the routes (routes.ts), INVALID_REQUEST (400: a body that isn't JSON, or no string appId), NOT_FOUND (404: "No such deploy API route.", also when the path's organization isn't the key's), DEPLOY_NOT_FOUND (404: polling a job id the Gateway doesn't have for that organization, run.ts jobAnswer), INTERNAL_ERROR (500: "The deploy API failed."); DEPLOY_KEY_APP_MISMATCH (a key bound to another app), INVALID_MANIFEST, INVALID_BUNDLE, BUNDLE_TOO_LARGE (over 64 MiB or 10,000 files), PROTOCOL_TOO_OLD (a stamp below the Gateway's floor), APP_NOT_REGISTERED, DEPLOY_IN_PROGRESS (one job at a time per app; "wait for it, then deploy again"), DEPLOYER_NOT_CONFIGURED, MIGRATION_FAILED ("Nothing was uploaded; the version that is serving keeps running."), BINDINGS_MISMATCH (the read-back differed: the Worker was deleted), DEPLOY_ABANDONED (never thrown: DeployJobRepository.toJob, apps/every-app-gateway/src/server/repositories/DeployJobRepository.ts, answers a job still running 5 minutes after it was created, DEPLOY_JOB_DEADLINE_MS, as failed with this code and "The deploy stopped without finishing: the gateway gives a deploy about 50 seconds, and a large app (many assets, slow migrations) can run out of them. Deploy again."; no heading on the errors page), BUNDLE_REFUSED.
BUNDLE_REFUSED and the tier limits (metadata.ts manifestProblems, bundleProblems and durableObjectMigrations; limits from platformPolicy(env).thirdPartyLimits: cpuMs 5,000, subrequests 200, durableObjectClasses 2; the same numbers for self-hosted browser installs, BROWSER_INSTALL_LIMITS). The message is The app can't be deployed as a third-party app: with one line per problem. The Gateway's deployer never deletes a Durable Object class (there is no --allow-delete-durable-object-data for it): durableObjectMigrations, run by the pipeline before anything is created (pipeline.ts) and again when the Worker's metadata is built, compares the manifest's classes with the live Worker's (the classes of the manifest last recorded, when the Worker has a migration tag) and refuses a dropped class; new classes are added through a migration tagged everyapp-v<n+1> (new_sqlite_classes, old_tag the live one) over the live Worker's everyapp-v<n> tag, and a live tag the deployer didn't write (not of that form) is refused.
| Problem |
|---|
it declares helper Workers (workers), which third-party apps can't have |
it declares Workflows, which third-party apps can't have yet |
it declares KV namespaces, which third-party apps can't have; use D1 |
it declares R2 or Hyperdrive, which the browser deployer can't provision; use the CLI |
it declares Email sending bindings (resources.sendEmail), which third-party apps can't have; send through the Gateway's email capability |
its migrations require a source checkout; browser installs support D1 SQL migrations only (a dialect other than sqlite or an engine other than drizzle) |
it declares secrets, which third-party apps can't have yet |
it declares inbound mail (email.inbound), which third-party apps can't receive |
a Durable Object class names a helper Worker |
it declares <n> Durable Object classes; a third-party app may have <limit> |
its Worker is named "<name>": a deploy through the gateway uploads the tenant-neutral build, uninstalled (everyapp deploy --gateway) |
it sets <key>, which third-party Workers can't (anything but name, compatibilityDate, compatibilityFlags, workersDev, previewUrls, observability, env, exports, manifest, assets, triggers) |
workersDev isn't false, previewUrls isn't false |
it declares triggers (crons or routes), which third-party apps can't have yet |
it has no compatibility date; its compatibility flags aren't a list; compatibility flag <flag> isn't allowed (allowed: nodejs_compat, nodejs_compat_v2, nodejs_als, global_fetch_strictly_public) |
its Build Output names no main module; the upload has no ES module <main>, the Worker's main module |
assets.<key> isn't allowed (only htmlHandling, notFoundHandling, runWorkerFirst) |
<NAME> is a <type> binding; the platform sets it as text / isn't the manifest's id "<id>" (for EVERYAPP_APP_ID, EVERYAPP_AUDIENCE, EVERYAPP_IDENTITY_PUBLIC_KEYS, EVERYAPP_IDENTITY_ISSUER) |
<NAME> is a <type> binding; the platform sets it (GATEWAY_URL, EVERY_APP_ORG_ID must be declared secret) |
<NAME> binds Worker "<worker>"; a third-party app binds only the gateway's AppGateway as EVERY_APP_GATEWAY |
<NAME> isn't a D1 database the manifest declares |
<NAME> binds another Worker's Durable Object ("<worker>"); <NAME> isn't a Durable Object class the manifest declares |
<NAME> is an assets binding, but the upload has no assets: the Build Output's assets directory is empty |
<NAME> is a secret; third-party apps can't have secrets of their own yet |
<NAME> is a <type> binding, which third-party apps can't have |
EVERYAPP_DEV is set, which only `everyapp dev` may do |
export <class> is a <type>, which third-party apps can't declare; Durable Object class <class> isn't one the manifest declares; Durable Object class <class> is <state>, which the deployer doesn't do |
migrations.binding <binding> isn't one of its D1 databases |
its Build Output has helper Workers (<names>), which third-party apps can't have (a release) |
it drops the Durable Object class <name>, which deletes its data; third-party deploys never do (several: classes <names>, which deletes their data; durableObjectMigrations) |
its live Worker has the migration tag "<tag>", which the deployer didn't write (durableObjectMigrations: a tag that isn't everyapp-v<n>) |
Browser installs
An owner or admin (on a self-hosted Gateway: an owner or admin of the install organization, from a session signed in within 10 minutes, and only with BROWSER_APP_INSTALLS=on) can install a public GitHub repository's latest release, or an immutable https://oss.new/apps/<key> release, from Admin → Apps → Install app (apps/every-app-gateway/src/server/deploy/install.ts: POST /api/admin/apps/install with a session, GET /api/admin/deploys/<id> to poll). The Gateway downloads https://github.com/<owner>/<repo>/releases/latest/download/everyapp-release.tar.gz with no credentials (HTTPS redirects only, 100 MB compressed and again unpacked), reads the tar itself, checks it as the CLI does (release.ts: RELEASE_INVALID, RELEASE_UNSUPPORTED, RELEASE_CORRUPT, RELEASE_TOO_LARGE, NO_RELEASE, RELEASE_FETCH_FAILED, INVALID_REPO_URL, INVALID_RELEASE_URL), refuses helper Workers, and runs the same startDeploy pipeline through the same registerApp (run.ts): on a hosted Gateway a dispatch app of that organization on THIRD_PARTY_DOMAIN; on a self-hosted Gateway (platformPolicy(env).mode === "self_hosted") a service_binding app under APP_BASE_DOMAIN ("Set APP_BASE_DOMAIN before installing apps." is DEPLOYER_NOT_CONFIGURED without it), the install organization (the oldest with an owner) getting the bare <appId>.<base> hostname and any other <appId>-<orgKey>.<base>; deployed_by_actor is the admin's email. Hosted Gateways keep the launch gate (APP_INSTALL_NOT_AVAILABLE, 404, otherwise); self-hosted Gateways have it on once CF_DEPLOY_TOKEN is configured with CLOUDFLARE_ACCOUNT_ID and APP_BASE_DOMAIN (selfHostedInstallEnabled, browser-install.ts; setup offers it; BROWSER_APP_INSTALLS=off opts out), and run it under the app's install lease (APP_CHANGING when the row changed meanwhile). Refusals before anything is downloaded (install.ts): UNAUTHORIZED (401: no session or a banned user, "Sign in again."; no membership in the chosen workspace, "Organization membership required."), INVALID_REQUEST (400: an invalid organizationId, "Choose a valid workspace."; a body that isn't JSON, or both repoUrl and releaseUrl), APP_INSTALL_FORBIDDEN (403: the signed-in person isn't an owner or admin of that organization; on a self-hosted Gateway, that organization isn't the install organization, the only one whose owners and admins may spend the account-wide CF_DEPLOY_TOKEN, and nobody installs before the Gateway has an owner; GET /api/admin/apps/install and GET /api/admin/apps/install/context answer by the person, whatever the session's active workspace, and the context offers no other workspace; a refusal of another organization is logged as app_install.refused at warn, with the organization ids and the user id), ORGANIZATION_SUSPENDED (403), DEPLOYER_NOT_CONFIGURED (503: the Gateway has no GATEWAY_URL or JWT_PUBLIC_KEY to give apps, run.ts deployerSettings; in the job, the CLI install row is missing, browser-install.ts); then BUNDLE_REFUSED on the release's own Worker before the row is written; and in the job DEPLOY_IN_PROGRESS (409: another install or CLI operation holds the lease) and LEASE_LOST (409). Runbook: docs/runbooks/browser-app-install.md at /docs/agent/repo/runbooks/browser-app-install; the hosted install itself: docs/runbooks/hosted-install.md at /docs/agent/repo/runbooks/hosted-install.
delete: what it removes and keeps
pnpm everyapp delete [<id>] [--purge] [--yes] [--gateway] [--account-id] [--json] [--verbose] (packages/cli/src/commands/delete/command.ts, index.ts); without an id, the app in this directory's everyapp.config.ts.
Confirmation first: at a terminal, Delete <app> from the Gateway? ("Its Worker and data stay; --purge deletes them too."); with --purge, the app id must be typed (Type <app> to confirm; a mismatch is CANCELLED, "Nothing deleted."). Unattended it needs --yes: CONFIRMATION_REQUIRED, "Deleting an app needs confirmation at a terminal, or --yes", rerun with --yes (authorize). --yes with --purge skips the typed id too.
Then (removeApp): the downgrade guard; app.delete (deleteApp, registry.ts): the row becomes a tombstone (status: deleted, is_default off, its names still its organization's), and in the same batch its email configuration and recorded secret names, everyone's user_app_access and group_app_access, the personal access tokens bound to it (revoked), its app_tools, app_tool_catalogs and mcp_connection_apps rows, and its org_app_settings (a monthly cap) go; a shared app needs a platform owner or admin. When the row wasn't already deleted, or the install row still lists its Worker or hostname, reconcileGateway (packages/cli/src/lib/gateway/reconcile.ts, not forced) redeploys the Gateway without the binding and the Custom Domain: when the live Gateway already binds exactly the apps the registry lists (registryDrift; a delete rerun whose binding is already gone) it emits step gateway.reconcile skip ("The gateway already binds the 1 app its registry lists." or "… all <n> apps …") and uploads nothing; otherwise gateway.release, gateway.lease, gateway.domains, gateway.migrations and gateway.deploy run as for a rebind. Then syncAppsAccessAfter with removing: true (whether the app had public routes is read with readPublicSurfaces before the row is tombstoned, so only a served row with a hostname counts): behind Cloudflare Access, app.access ok "Cloudflare Access no longer lets <app>'s public routes through." (a person sees it) or skip "Cloudflare Access had nothing of <app>'s to take back."; ACCESS_NOT_CHECKED ends "If the Gateway is behind Access, <app>'s host stays listed there until it is updated." and ACCESS_SYNC_FAILED "<app>'s host stays listed there until it is updated.", with the same data.rerun pnpm everyapp access on. The hostname's certificate packs are deleted (app.certificate, packages/cli/src/lib/domains/certificates.ts; never one that would leave the apex without an active certificate; a failure is warning CERT_CLEANUP_FAILED).
Without --purge the Worker and data are kept: "its Worker and data are kept, and any cron triggers, Workflows and mail routes keep running. To delete those too: pnpm everyapp delete <app> --purge". With --purge (app.purge): the mail routes first, then the Workflows that run on the app's recorded Workers, then (when it recorded a queue) every queue consumer that is one of its Workers (cf queues consumers delete <consumer-id> --queue-id <queue-id> --force: Cloudflare won't delete a Worker that consumes a queue, [10064]), then the Workers (the app's before its helpers), D1 databases, KV namespaces, Hyperdrive configs, queues (cf queues delete <id> --force, with their messages; after the Workers, since a queue a Worker binds can't be deleted, [11005]: one a Worker outside the app still binds is kept and said so), Vectorize indexes (cf vectorize delete <name> --force, their vectors with them), AI Gateways (with their stored logs) and Secrets Store secrets (cf secrets-store secrets delete <id> --store-id <store> --force; the store stays), only those the ledger records as created by the app (never whatever has its names; a Postgres database behind Hyperdrive is never touched), then the ledger is cleared. Anything under the app's names that isn't recorded stays with warning RESOURCES_KEPT and where to delete it by hand. done.data: appId, workerName, alreadyDeleted, purged (true when nothing of it is left), removed, kept. A delete that stopped part-way runs again safely: a tombstone writes nothing, and a Gateway that still binds it is reconciled again.
Sources
packages/cli/src/commands/deploy/index.ts
packages/cli/src/commands/deploy/command.ts
packages/cli/src/commands/deploy/deployApp.ts
packages/cli/src/commands/release/bundleApp.ts
packages/cli/src/commands/deploy/steps/checkSchemaDrift.ts
packages/cli/src/commands/deploy/steps/offerGateway.ts
packages/cli/src/commands/deploy/steps/waitForLive.ts
packages/cli/src/commands/appGateway.ts
packages/cli/src/commands/flags.ts
packages/cli/src/commands/delete/command.ts
packages/cli/src/commands/delete/index.ts
packages/cli/src/lib/app/buildApp.ts
packages/cli/src/lib/app/buildOutput.ts
packages/cli/src/lib/app/installBuildOutput.ts
packages/cli/src/lib/app/policy.ts
packages/cli/src/lib/app/resources.ts
packages/cli/src/lib/app/ownership.ts
packages/cli/src/lib/app/workflows.ts
packages/cli/src/lib/app/deploySecrets.ts
packages/cli/src/lib/app/release.ts
packages/cli/src/lib/app/skill.ts
packages/cli/src/lib/app/secrets.ts
packages/cli/src/lib/app/emailRoutes.ts
packages/cli/src/lib/cf/aiGateway.ts
packages/cli/src/lib/release/fetch.ts
packages/cli/src/lib/release/github.ts
packages/cli/src/lib/migrations/remote.ts
packages/cli/src/lib/migrations/postgres.ts
packages/cli/src/lib/migrations/prisma.ts
packages/cli/src/lib/migrations/localCli.ts
packages/cli/src/lib/gateway/lease.ts
packages/cli/src/lib/gateway/registry.ts
packages/cli/src/lib/gateway/apply.ts
packages/cli/src/lib/gateway/checkoutRelease.ts
packages/cli/src/lib/gateway/connect.ts
packages/cli/src/lib/gateway/install.ts
packages/cli/src/lib/gateway/discover.ts
packages/cli/src/lib/gateway/reconcile.ts
packages/cli/src/lib/access/sync.ts
packages/cli/src/lib/access/plan.ts
packages/cli/src/lib/cf/access.ts
packages/cli/src/commands/access/index.ts
packages/cli/src/lib/gateway/restorePoints.ts
packages/cli/src/lib/gateway/release.ts
packages/cli/src/lib/cf/deploy.ts
packages/cli/src/lib/cf/account.ts
packages/cli/src/lib/cf/d1.ts
packages/cli/src/lib/cf/secrets.ts
packages/cli/src/lib/domains/preflight.ts
packages/cli/src/lib/domains/hostnames.ts
packages/cli/src/lib/domains/records.ts
packages/cli/src/lib/domains/zones.ts
packages/cli/src/lib/domains/authoritativeDns.ts
packages/cli/src/lib/domains/certWait.ts
packages/cli/src/lib/domains/certificates.ts
packages/cli/src/lib/domains/https.ts
packages/cli/src/lib/domains/localDns.ts
packages/cli/src/lib/errors.ts
packages/cli/src/lib/checkout.ts
packages/cli/src/lib/output/events.ts
packages/cli/src/lib/output/prompt.ts
packages/cli/src/lib/package-manager.ts
packages/sdk/src/cloudflare/sentinel.ts
packages/sdk/src/cloudflare/worker.ts
packages/perimeter/src/manifest/manifest.ts
packages/perimeter/src/manifest/deploy.ts
packages/perimeter/src/registry/registration.ts
apps/every-app-gateway/src/server/deploy/pipeline.ts
apps/every-app-gateway/src/server/deploy/run.ts
apps/every-app-gateway/src/server/deploy/bundle.ts
apps/every-app-gateway/src/server/deploy/install.ts
apps/every-app-gateway/src/server/deploy/release.ts
apps/every-app-gateway/src/server/deploy/routes.ts
apps/every-app-gateway/src/server/deploy/errors.ts
apps/every-app-gateway/src/server/deploy/metadata.ts
apps/every-app-gateway/src/server/deploy/auth.ts
apps/every-app-gateway/src/server/deploy/browser-install.ts
apps/every-app-gateway/src/server/deploy/registry.ts
apps/every-app-gateway/src/server/deploy/cfRestDeployer.ts
apps/every-app-gateway/src/server/repositories/DeployJobRepository.ts
apps/every-app-gateway/src/server/platform-policy.ts
starter/package.json
docs/security-model.md
docs/runbooks/hosted-install.md
docs/runbooks/browser-app-install.md
adr/ADR-0003-self-host-authority.md
MCP: tools, the connector and OAuth
How an app exposes tools to AI clients with defineTools(), how the Gateway serves them on its one /mcp connector under app-prefixed names, and how claude.ai, Claude Code, Cursor and scripts connect with OAuth or a personal access token.
API providers: the registry and the catalog
What an API provider is (beside an MCP app), the built-in catalog and the providers one Gateway registers, the spec's JSON fields and their rules, the request each auth kind makes, what a registered row may say, everyapp provider add, list, show and remove with their --json data, the codes, local development, and every catalog provider.