Environment variables
September 18, 2026 · View on GitHub
Use this guide when you add a new environment variable to the worker app. It keeps types, runtime validation, and documentation in sync.
Steps
-
Add the type
- Update
packages/worker/src/env-schema.tsso the worker schema andAppEnvinclude the new variable. packages/worker/env.d.tsextendsEnvfrom that worker-owned schema.
- Update
-
Validate at runtime
- Add the variable to the runtime schema in
packages/worker/src/env-schema.ts. packages/worker/src/app/env.tsuses the schema to fail fast at runtime.- The schema is the single source of truth for validation + types.
The schema is built with
remix/data-schema(createSchema,object,string), not Zod. Example:export const EnvSchema = object({ COOKIE_SECRET: string().refine( (value) => value.length >= 32, 'COOKIE_SECRET must be at least 32 characters for session signing.', ), THIRD_PARTY_API_KEY: string().refine( (value) => value.length > 0, 'Missing THIRD_PARTY_API_KEY. Go to https://example.com/ to get one.', ), }) - Add the variable to the runtime schema in
-
Add local defaults
- Update
packages/worker/.env.example(source for new localpackages/worker/.envfiles).
- Update
-
Update required resources docs
- Add the variable to
docs/contributing/setup-manifest.md.
- Add the variable to
-
Sync deploy config
- Add the variable to the relevant GitHub Actions workflows so it is passed
to Wrangler as a Worker var or secret, depending on sensitivity:
.github/workflows/deploy.yml(production deploys).github/workflows/preview.yml(preview deploys)
- Secrets that platform, runtime, or jobs workers read must be synced onto
those sibling scripts as well as origin. A secret only on
kody-productionis invisible tokody-platform/kody-runtime/kody-jobs. See architecture.
- Add the variable to the relevant GitHub Actions workflows so it is passed
to Wrangler as a Worker var or secret, depending on sensitivity:
Sentry
Optional Worker secret and vars (see packages/worker/src/env-schema.ts and
packages/worker/src/sentry-options.ts):
SENTRY_DSN— ingest URL from your Sentry project. When unset, the Worker skipsSentry.withSentry; Durable Objects use the same options builder and will not send events without a DSN. The DSN (a publishable client key) is also exposed to the browser via thekody:sentrymeta tag to enable client error capture and error-only session replay through the same-origin/sentry-tunnelroute.SENTRY_ENVIRONMENT— also set as a Wranglervarper environment inpackages/worker/wrangler.jsoncfor deploys.SENTRY_TRACES_SAMPLE_RATE— optional0–1; defaults to1.0(sample all traces) when unset. Production pins it to0as a Wranglervarinpackages/worker/wrangler.jsoncbecause Workers native tracing already exports OTLP traces to Sentry and duplicate SDK traces would double quota usage; error reporting is unaffected. The Sentry options builder reads the raw runtime env and only honors a number, so configure it as a JSON-numbervar, not a string secret.
Fathom Analytics
Optional Wrangler var (public, non-secret; see
packages/worker/src/env-schema.ts and
packages/worker/src/app/ssr-document.tsx):
FATHOM_SITE_ID— the Fathom Analytics site id. When set, SSR pages embed the Fathom tracker script (https://cdn.usefathom.com/script.jswithdata-spa="auto"so SPA navigations are tracked). Production sets it inpackages/worker/wrangler.jsonc; it is intentionally unset for local dev, preview, and tests so those environments never send pageviews. The CSP inpackages/worker/src/app/security-headers.tsallowlistshttps://cdn.usefathom.cominscript-src,img-src, andconnect-srcfor the tracker, its image pageview beacon, andsendBeaconduration/event pings. After a production domain change, add the new hostname to the site's Firewall → Domains Allow list (Settings → Sites → site → Firewall). A leftoverheykody.*Allow list discardskody.codespageviews while the collect GIF still returns 200. The API token cannot read or write that list.connect-srcis required forsendBeaconduration/events, not for the image pageview beacon.
YouTube watch overlay
Optional Wrangler vars (public, non-secret; see
packages/worker/src/env-schema.ts and
architecture/youtube-watch.md):
YOUTUBE_ALLOWED_PLAYLIST_IDS— comma-separated playlist ids. The Worker reads each playlist's public Atom feed (latest ~15 videos) and caches it about an hour.nonedisables playlists. Unset skips playlist fetch so tests stay offline. Production and preview set Kent's public playlist inpackages/worker/wrangler.jsonc.YOUTUBE_ALLOWED_VIDEO_IDS— comma-separated extra video ids, merged with playlist items, the look-preview sample id, homepage hero chooser ids, and ids extracted from enabled banner hrefs.
Optional origin-only Worker secret (see packages/worker/src/env-schema.ts and
architecture/youtube-watch.md):
YOUTUBE_DATA_API_KEY— YouTube Data API key for the homepage hero chooser (playlistItems, playlist order). When unset, the Worker reads the same unlisted playlist through YouTube's public browse endpoint. Production deploy syncs it onto origin only (--set-from-env-optional YOUTUBE_DATA_API_KEY). Platform, runtime, and jobs workers do not read it.
The overlay itself is /?youtubeId=<id>. Thumbnails are proxied at
/youtube-thumb/<id> so img-src can stay first-party.
Build metadata
Optional Wrangler vars set by the production and preview deploy workflows (see
packages/worker/src/deploy-info.ts and tools/ci/build-deploy-info.ts):
APP_COMMIT_SHA— the git SHA being deployed.GET /healthreports it ascommitShaso post-deploy healthchecks can pin the version. Also used as the Sentry release.APP_DEPLOY_INFO— base64url JSON (raw JSON is also accepted) with the commit message and date, repo/PR links, and the GitHub Actions run that deployed.GET /healthdecodes it; invalid values are ignored so a bad var cannot take the worker down. Unset in local dev.
App origin and domain migration
Wrangler vars (public and non-secret; see
packages/worker/src/app-base-url.ts,
packages/worker/src/app-legacy-redirect.ts, and tools/ci/resource-utils.ts):
APP_BASE_URL— the canonical public app origin (https://kody.codesin production, set as a GitHub Actions repository variable and injected by the deploy). Unset for local dev and set to the ephemeral worker URL for previews. The deploy derives a Workerscustom_domainroute from it, andgetCanonicalAppBaseUrluses it for canonical/OG URLs in SSR HTML.APP_LEGACY_HOSTS— optional comma-separated additional app hostnames that remain attached and dual-served alongsideAPP_BASE_URL. The generated deployrouteslist replaces the Worker's entire custom-domain set, so every dual-served host must be listed here — otherwise the next deploy detaches omitted origins and deletes their DNS records. Set as a GitHub Actions repository variable. Production leaves this unset.APP_LEGACY_REDIRECT— exact stringtrueenables path-and-query-preserving308redirects from legacy hosts to the canonical origin for browser GET/HEAD navigation only. Protocol surfaces are never redirected:/mcp(clients POST and do not follow redirects),/oauth/*and/.well-known/*(origin-exact metadata, Tesla public key),/auth/*and/webauthn/*(per-origin callbacks and passkeyrpID),/connect/oauth,/health*,/__maintenance/*, webhooks, and package invocation APIs. Leave unset to dual-serve legacy hosts without redirecting browser navigation.
Hosted package app origin
Wrangler var (public and non-secret; required in production, optional in
confirmed non-production runtimes; see packages/worker/src/app-base-url.ts and
packages/worker/src/app/package-app-origin.ts):
-
PACKAGE_APP_BASE_URL— the apex origin of the package-app domain that hosted package apps are served from. Production setshttps://kody.runinpackages/worker/wrangler.jsonc, and the deploy publishes zone routes for the apex (<apex-host>/*) and the per-user wildcard (*.<apex-host>/*) on the runtime Worker — never a custom domain in this zone (replacing a zone's route table detaches its custom domains and deletes their DNS records). Zone routes do not create DNS records, so production CI ensures proxied placeholder records for both names separately (see setup-manifest.md). Each owner's apps are addressed athttps://{username}.<apex-host>/packages/{kodyId}/...; the apex itself serves only redirects (legacy/@user/packages/...paths to the owning subdomain,/to the app origin). It must be a separate registrable domain fromAPP_BASE_URL: that is what makes author-supplied package code cross-site, so theSameSite=Laxkody_sessioncookie never reaches it. Production origin validation also requiresAPP_BASE_URLso this relationship can be checked at runtime. Production returns500for package-app requests when this value is missing, invalid, equal toAPP_BASE_URL, or on the same registrable domain; it never falls back to inline serving. Preview, tests, and E2E may leave it unset and keep serving package apps inline on the app origin at/@{username}/packages/*.npm run devruns the production Wrangler environment, so the committed production value reaches local dev too;getPackageAppBaseUrlignores an origin a local server cannot answer on, which keepsnpm run devinline. SetPACKAGE_APP_BASE_URL=http://packages.localhost:<port>inpackages/worker/.envto exercise the two-origin flow locally. See Hosted package app origin isolation. -
PACKAGE_APP_LEGACY_HOSTS— optional comma-separated dual-served package-app apex hostnames alongsidePACKAGE_APP_BASE_URL. Generated runtime zone routes replace the Worker's whole route set, so every dual-served package-app host must be listed here — otherwise the next deploy detaches omitted origins and deletes their DNS records. This variable may also be set as a GitHub Actions repository variable (non-empty overlay wins). Production leaves this unset. -
PACKAGE_APP_LEGACY_REDIRECT— exact stringtrueenables path-and-query- preserving308redirects from dual-served package-app user subdomains ({username}.<legacy-apex>→{username}.kody.run) for browser GET/HEAD only. Leave unset to dual-serve: package-app session cookies use the__Host-prefix, so they are host-only and cannot follow a redirect. Apex/on a dual-served package-app host redirects to the app origin (kody.codes); it is never sent to the canonical package-app apex. Dual-serve is the default; set this GitHub Actions repository variable totrueonly when enabling those GET/HEAD redirects. Production leaves this unset.
MCP execute and outbound HTTP
MCP execute runs sandboxed JavaScript with a global fetch. Calls to
third-party APIs can use stored secrets via {{secret:name}} placeholders in
URLs and headers where the MCP runtime supports them. Host allowlists and
capability policies apply per secret. There are no GitHub-specific Worker
environment variables.
Social login (GitHub / Google / X / Discord)
Optional Worker secrets (see packages/worker/src/app/oauth-providers.ts and
social-login.md):
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETX_CLIENT_ID/X_CLIENT_SECRETDISCORD_CLIENT_ID/DISCORD_CLIENT_SECRETDISCORD_BOT_TOKEN/DISCORD_GUILD_ID/DISCORD_MEMBER_ROLE_ID/DISCORD_STANDARD_ROLE_ID/DISCORD_PRO_ROLE_ID(optional; official Kody Discord guild join and role sync. Bot token + guild id enable Add Guild Member during Discord social login; at least one role id enables role writes. The bot needs Create Instant Invite and Manage Roles. Standard/Pro followusers.stripe_plan.)
A provider's login button only renders when both of its values are set. A
MOCK_-prefixed client id activates the in-worker mock provider flow on
non-production runtimes (used by local dev and E2E tests). In GitHub Actions the
values live under OAUTH_-prefixed secret names because Actions reserves
GITHUB_*; the deploy workflow maps them to the unprefixed Worker secrets.
Kit subscriber tags
Optional Worker secrets / vars for exist-only Kit tagging on account events:
KIT_API_KEY— Kit v4 API key (X-Kit-Api-Key). Preview deploys omit the key so they never write to the production Kit audience.KIT_SIGNED_UP_TAG_ID— optional override for the Kit tag applied on account signup when the email already exists in Kit (defaults tosigned_up::kody). Account events never create Kit subscribers and never fail when Kit is unset. When a subscriber already exists, lifecycle tags are added in place (signed_up::kody,verified::kody,agent_connected::kody,activated::kody, plusstandard::kody/pro::kodyfrom Stripe). Paid tags are removed on cancel. The hourlykit_subscriber_synclane reconciles the same exist-only tags.
See architecture/authentication.md.
Stripe billing
Optional Worker secret and vars for account subscription billing
(packages/worker/src/billing/, routes under /account/billing). When
STRIPE_SECRET_KEY is unset, billing is disabled: the account billing page
shows plan info plus a "not configured" notice, and success/portal/cron skip
safely. Manual users.plan grants apply regardless.
STRIPE_SECRET_KEY— Stripe secret API key. Required for checkout linking, portal sessions, andstripe_planrefresh. Synced as a Worker secret from the GitHub Actions secret of the same name on production deploy when set.STRIPE_WEBHOOK_SECRET— Stripe endpoint signing secret (whsec_...) forPOST /webhooks/stripe. When unset, the webhook endpoint returns 503. Synced as a Worker secret from the GitHub Actions secret of the same name on production deploy when set.STRIPE_API_BASE_URL— optional API base URL; defaults tohttps://api.stripe.comwhen unset. Override for tests/mocks.STRIPE_STANDARD_PRICE_ID— Stripe Price id for the $12/monthstandardplan.STRIPE_STANDARD_YEARLY_PRICE_ID— Stripe Price id for the $120/yearstandardplan ($10/month billed annually).STRIPE_PRO_PRICE_ID— Stripe Price id for the public $49/monthprocheckout price (price_1UChg1LAQpAnsYszAYn6eGgtonprod_V1ChgPPenrxsAXin production).STRIPE_PRO_YEARLY_PRICE_ID— Stripe Price id for the public $480/yearprocheckout price (price_1UChg2LAQpAnsYszKAFCR778, $40/month billed annually).STRIPE_BILLING_PORTAL_CONFIGURATION_ID— optional Stripe Billing Portal configuration id (bpc_...) passed asconfigurationwhen creating portal sessions for Manage subscription and for plan changes by existing subscribers. The production configuration enablessubscription_updatewithproration_behavior=always_invoice, allows price switches among the public Standard $12/$120 and Pro $49/$480 checkout prices, cancel at period end, payment method and customer updates, and invoice history. Previous Pro list prices stay active in Stripe off-portal so existing subscribers keep their plan. When unset (preview, test, local), Stripe uses the account's default portal configuration.
Each price id independently enables authenticated Checkout and subscription
matching for its tier and interval; leaving a monthly or yearly id unset makes
only that interval unavailable for purchase. Price ids and the portal
configuration id are public (non-secret) values committed as production Wrangler
vars in packages/worker/wrangler.jsonc, not Worker secrets. Historical
$5
Standard and previous Pro monthly/yearly price ids remain in
retiredStandardPriceIds / retiredProPriceIds in
packages/worker/src/billing/billing-config.ts so existing subscribers keep
their plan after checkout ids rotate. Those retired Pro prices stay active
off-portal; the Stripe Billing Portal configuration
(STRIPE_BILLING_PORTAL_CONFIGURATION_ID) lists only the public Standard
$12/$120 and Pro $49/$480
checkout prices.
Dashboard-side dunning (not an environment variable, recorded here so it
survives re-provisioning): the production Stripe account has every customer
email under Settings → Billing → Subscriptions and emails → "Email notifications
and customer management" enabled (trial-ending reminder, upcoming renewals,
expiring cards, failed card payments, failed bank-debit payments), and "Manage
failed payments" cancels the subscription when all retries fail. Kody also sends
its own past-due and payment-failed emails (billing/subscription-sync.ts,
billing/stripe-webhooks.ts), deduplicated so one failed charge is not two Kody
emails; the Stripe email is additive and carries the card-update link.
See architecture/entitlements.md (Billing).
MCP OIDC ID token signing
Required Worker configuration for MCP OAuth OpenID Connect ID tokens (RS256):
OIDC_SIGNING_KEY_ID— JWT headerkid(non-empty string). Production should use a deployment-specific value (for examplekody-oidc-2026-09).OIDC_SIGNING_PRIVATE_KEY_PEM— PKCS#8 RSA private key PEM. Generate withopenssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out oidc.pem. Local dev and tests use the committed example key inpackages/worker/.env.example. Production must use a dedicated key synced as a Worker secret (not a Wranglervar).
Public JWKS is derived from the private key at /.well-known/jwks.json.
Saved-secret encryption (SECRET_STORE_KEY)
Required Worker secret used to derive the AES-GCM key for encrypting saved secrets at rest in D1.
- Every environment must set
SECRET_STORE_KEY, including local dev and CI, so saved secrets can be encrypted and decrypted. - See
docs/contributing/secret-rotation.mdfor rotation procedures.
MCP capability search (Vectorize + Workers AI)
Worker bindings (see packages/worker/wrangler.jsonc):
CAPABILITY_VECTOR_INDEX— Cloudflare Vectorize index for semantic retrieval (kody-capabilities-prod/kody-capabilities-preview). Create indexes with--dimensions=384 --metric=cosineto match@cf/baai/bge-small-en-v1.5withclspooling (seepackages/worker/src/vectorize/embedding.ts). ThetestWrangler environment omits this binding sonpm run testand e2e use the deterministic offline fusion path (offline: truein search results).AI— Workers AI binding used by production and preview capability, memory, job, and saved-package embedding calls, and by ranked MCP search Jev Score (typesafe/jev) whenjev-search-rerankis on. Local dev and tests do not require it becauseWRANGLER_IS_LOCAL_DEV,SENTRY_ENVIRONMENT=test, or a missing non-production binding keeps search on the deterministic offline path.
Worker secrets:
AI_GATEWAY_ID— Cloudflare AI Gateway id. When set, embedding and Jev Score calls use the Workers AI bindinggatewayoption so production and preview inference is logged/routed through AI Gateway. When unset, embeddings call Workers AI directly;typesafe/jevdoes not (that third-party model requires AI Gateway plus Unified Billing or BYOK). The configured gateway must have authentication enabled and a non-zero Unified Billing balance, or a BYOK key; authentication off yields HTTP 403, zero credits yields HTTP 402.CAPABILITY_REINDEX_SECRET— strongly recommended for production (CI skips the post-deploy capability reindex and origin-only execute smoke check when it is unset); bearer token forPOST /__maintenance/reindex-capabilities,POST /__maintenance/reencrypt-secrets, and other secret-gated maintenance endpoints. Production deploy POSTs{ "phases": ["capabilities"] }so only builtin capability vectors refresh after a ship. User-owned memory, job, and saved-package vectors upsert on write. Omitphases(or list every kind) after changing the embedding model, pooling, or Vectorize index dimensions to rebuild those rows too, with per-useruserIdmetadata on user-owned rows. Unchanged embed text and Vectorize metadata skip embed and Vectorize upsert; after Vectorize data loss or a pooling-only change, POST{ "force": true }(and omitphasesfor every kind) so matching fingerprints cannot skip upserts. Each call is time-budgeted and may returncomplete: falseplus acursorto resume. Use the re-encrypt endpoint to rewrite pre-AAD (2-part) secret ciphertexts to v2 without rotatingSECRET_STORE_KEY— including after restoring a D1 export sealed before the 2026-08-17 format-upgrade pass (see Secret rotation and Disaster recovery). Local dev uses offline search whileWRANGLER_IS_LOCAL_DEVis set or the binding is missing.JOB_REINDEX_SECRET— optional Worker secret; bearer token forPOST /__maintenance/reindex-jobswhen you want a jobs-only Vectorize rebuild (without a full capability/memory/package reindex). When unset, the jobs-only endpoint returns not-configured. Production deploys do not refresh job vectors; those upsert on write. Use this secret or a full capability reindex when you need a jobs rebuild. After Vectorize data loss, usePOST /__maintenance/reindex-capabilitieswith{ "force": true }rather than the jobs-only endpoint so restored D1 fingerprints cannot skip an empty index.STATUS_INCIDENT_EVENT_SECRET— optional Worker secret shared with the status worker. Bearer token for originPOST /__maintenance/status-incidents, which fansstatus.incident.opened/status.incident.resolvedto admin package subscriptions, and for statusPOST /__maintenance/incidents/:id/retrospective, which attaches a public writeup to a resolved probe-derived incident. When unset, the origin endpoint returns not-configured, the status worker skips emit, and the retrospective route returns 503; packages can reconcile open/resolve from public/status.json. Synced from the GitHub Actions secret of the same name on production deploy.
Cloudflare API (Worker + Email)
Optional Worker secrets/vars (see packages/worker/src/env-schema.ts and
packages/worker/src/mcp/cloudflare/cloudflare-rest-client.ts):
CLOUDFLARE_API_TOKEN— Cloudflare API token used by the internal API client (Authorization: Bearer ...) for Worker-side Cloudflare REST calls such as the Cloudflare Email sender. User Cloudflare API calls from authored package modules use saved secrets and secret-awarefetch(seedocs/contributing/packages-and-manifests.md). Localnpm run devsets this to the Cloudflare mock token unlessSKIP_CLOUDFLARE_MOCK=1. Production workers do not share one value:kody-runtimereceives the narrowerCLOUDFLARE_RUNTIME_API_TOKENGitHub secret (Email Sending + Artifacts only) via--set-from-env-optional CLOUDFLARE_API_TOKEN=CLOUDFLARE_RUNTIME_API_TOKENin.github/workflows/deploy.yml; seedocs/contributing/setup-manifest.mdfor the permission list.CLOUDFLARE_ACCOUNT_ID— Cloudflare account id required by the Cloudflare Email Service REST API fallback used by local mocks and preview deploys. This is a Worker var (not a secret) and should match the account behindCLOUDFLARE_API_TOKEN.CLOUDFLARE_API_BASE_URL— API base URL; defaults tohttps://api.cloudflare.comwhen unset, including for outbound email sending. Localnpm run devsets this to the Cloudflare mock Worker unlessSKIP_CLOUDFLARE_MOCK=1. That same local mock serves the Artifacts REST control-plane endpoints used bypackages/worker/src/repo/artifacts.ts(repos,tokens, andfork), so local repo create/get/list/token/fork calls do not need the live Artifacts REST API.CLOUDFLARE_API_SOURCE_SNAPSHOTS— set totrueonly whenCLOUDFLARE_API_BASE_URLpoints at a local Cloudflare API stand-in that can return a repo's whole tree at a commit (npm run devand the MCP e2e harness set it alongside the mock). The real Cloudflare API has no such endpoint, so production leaves it unset andpackages/worker/src/repo/artifact-source-snapshot.tsreturnsnullwithout a request; published trees come fromBUNDLE_ARTIFACTS_KVsnapshots.USER_EMAIL_DOMAIN— optional override for the user email domain (seepackages/worker/src/email/platform-address.ts). Defaults toinbox.<APP_BASE_URL hostname>(for exampleinbox.kody.codes): every user inbox and user outbound sender lives at{username}@<this domain>. User mail deliberately lives on a subdomain so the user-controlled namespace and its sender reputation stay separate from system transactional mail (kody@<apex>). The deployment's Cloudflare zone needs Email Routing enabled for this subdomain (Email > Email Routing > Settings > Add subdomain) with its catch-all routed to the Worker. Production commitsUSER_EMAIL_DOMAIN=inbox.kody.codesinpackages/worker/wrangler.jsoncso the domain can never silently rederive fromAPP_BASE_URL; the deploy tooling (tools/ci/production-resources.ts) reads the same committed pin when configuring the Email Sending event subscription.SYSTEM_EMAIL_DOMAIN— optional override for the system email domain (thekody@<domain>transactional sender and operator system inboxes). Defaults to theAPP_BASE_URLhostname. Production commitsSYSTEM_EMAIL_DOMAIN=kody.codes. Signup, email-change, and password-reset messages also put this host on their action and asset links. Localnpm run devkeeps those links on the request origin so they stay clickable.LEGACY_USER_EMAIL_DOMAINS/LEGACY_SYSTEM_EMAIL_DOMAINS— optional comma-separated additional email domains that inbound mail is accepted on alongside the canonical domains (seepackages/worker/src/email/platform-address.ts). Delivery resolves to the same inboxes; outbound always sends from the canonical domains. Production leaves these unset.ARTIFACTS_NAMESPACE— Cloudflare Artifacts namespace for repo REST calls and for choosing the env-scopedARTIFACTSbinding. Defaults todefaultwhen unset (local dev and tests). Wrangler setsproductionandpreviewper environment inpackages/worker/wrangler.jsonc. Production/preview also bindARTIFACTS(JSRPC); create/get prefer that binding and fall back to REST. New repo sessions persist this value in D1 assession_repo_namespaceso follow-up lookups resolve the correct namespace even after env changes.
Disaster recovery (production Worker)
Optional production-only vars/secrets for the nightly non-D1 staging exporter
and chunked restore endpoint (packages/worker/src/dr/). All are inert until
DR_EXPORT_ENABLED is the literal string "true" and S3 credentials are set.
See Disaster recovery.
DR_EXPORT_ENABLED— Worker var; enable staging only when"true".DR_BACKUP_ACCOUNT_ID/DR_BACKUP_BUCKET_NAME— DR account id and backup bucket name (S3 API endpoint host uses the account id).DR_BACKUP_ACCESS_KEY_ID/DR_BACKUP_SECRET_ACCESS_KEY— Worker secrets; R2 S3 credentials that can writestaging/andblobs/in the DR bucket.BACKUP_MANIFEST_SIGNING_KEY_ID/BACKUP_MANIFEST_VERIFYING_PUBLIC_KEY_SPKI_BASE64— public verification material shared with the DR control plane. The Mailbox importer fails closed when either differs from the signed full manifest.DR_RESTORE_SECRET— Worker secret; bearer token forPOST /__maintenance/dr-restore,POST /__maintenance/dr-mailbox-import, and the operator-onlyPOST /__maintenance/do-pitr. Must match the control-plane secret of the same name. Fail-closed when unset.
Backup control plane (DR account Worker)
Lives under packages/backup-control-plane/ in the DR Cloudflare account. Code
deploys via the production GitHub Actions workflow when control-plane / shared
backup contract or backup-resource paths change. GitHub Actions requires
DR_DEPLOY_TOKEN and DR_BACKUP_ACCOUNT_ID. An optional
DR_BACKUP_ADMIN_TOKEN with DR-account Workers R2 Storage Write reconciles and
reads back the lock/lifecycle policy before the Worker deploy. When unavailable,
Actions logs a reconciliation skip and deploys normally. The admin token is
never installed as a Worker secret. Enable gates and source identity vars live
in that package's wrangler.jsonc.
Workflow bindings are BACKUP_WORKFLOW and RESTORE_WORKFLOW.
Non-secret vars:
ENABLE_PRODUCTION_D1_BACKUPS/BACKUP_BENCHMARK_APPROVED— both must be exactly"true"or schedules stay inert.SOURCE_ACCOUNT_ID/SOURCE_DATABASE_ID/SOURCE_DATABASE_NAMEplusSOURCE_DATABASES(JSON array of{ id, name }objects; the nightly path exports every entry) andALLOWED_SOURCE_ACCOUNT_IDS/ALLOWED_SOURCE_DATABASE_IDS.SOURCE_DATABASE_ID/SOURCE_DATABASE_NAMEare the primary APP_DB (kody) identity and the single-database fallback whenSOURCE_DATABASESis unset. Every listed id must appear inALLOWED_SOURCE_DATABASE_IDS. Productionkody-jobsUUID is the live D1 namedkody-jobs; deploy resolves it by name intools/ci/jobs-worker-resources.ts(jobs_d1_database_id).BACKUP_MANIFEST_SIGNING_KEY_ID,BACKUP_MANIFEST_VERIFYING_PUBLIC_KEY_SPKI_BASE64,TRUSTED_RESTORE_BASELINE_ID,TRUSTED_RESTORE_BASELINE_SHA256.BACKUP_MAX_AGE_HOURS(default 26),BACKUP_MAX_SOURCE_BYTES(≤ 4.5e9).ACCESS_TEAM_DOMAIN,ACCESS_APP_AUD,ACCESS_ALLOWED_EMAIL— Zero Trust Access JWT verification (policy pinned to the solo operator email).DRILL_ACCOUNT_ID— isolated account for UI restore drills (must differ from source).PRIMARY_WORKER_ORIGIN— production Worker origin for/__maintenance/dr-restore.
Secrets (Wrangler secret storage only — never .env, config, logs, or
evidence):
CLOUDFLARE_API_TOKEN— production-account Account D1 Edit (export + production import).BACKUP_MANIFEST_SIGNING_PRIVATE_KEY_PKCS8_BASE64— base64 Ed25519 PKCS#8.DRILL_API_TOKEN— drill-account D1 Edit for isolated UI drills.RESTORE_CONFIRM_SECRET— HMAC secret for the 10-minute prepare→execute production-restore token.DR_RESTORE_SECRET— bearer shared with the production Worker.
GitHub Actions workflow_dispatch escrow (.github/workflows/dr-escrow.yml)
also needs SECRET_STORE_KEY, SECRET_ESCROW_PASSPHRASE, and the DR S3
credentials (DR_BACKUP_*) as repository secrets.
Why a schema?
The remix/data-schema env schema gives type inference for Env-driven values
and a single runtime gate that fails fast with clear errors. It keeps the
“what’s required” definition in one place.