Skip to content

Cloudflare Workers for Platforms

Deploy authhero as a multi-Worker SaaS platform where each tenant (publisher) gets their own isolated authhero Worker, fronted by a thin dispatcher Worker that routes by custom domain.

Two-tier deploy

WFP authhero is two Workers, not one:

  1. Dispatcher@authhero/proxy running as a Worker. Resolves the request's Host header to a tenant, then dispatches to that tenant's Worker via a dispatch namespace binding.
  2. Tenant Worker(s) — the full authhero app, one Worker per tenant, deployed into the dispatch namespace.

When to use this

The default Cloudflare Workers deployment runs one Worker that handles all tenants — tenant resolution happens inside the Worker via the Host header. That's the right answer for most deployments.

Reach for Workers for Platforms (WFP) when you need:

  • Per-tenant isolation — a bug or runaway request in one tenant's Worker can't affect others
  • Per-tenant code customization — publishers can ship modified authhero builds (different login flow, custom hooks, special branding logic) without a shared deploy
  • Per-tenant resource limits — CPU time and subrequest caps applied per script
  • A true platform model — you operate authhero as a SaaS where customers deploy "into" you

The trade-off is operational cost: every tenant has its own deployment lifecycle (worker upload, optional D1 creation, secrets distribution, custom domain wiring). The rest of this page is the playbook for that lifecycle.

Architecture

The dispatcher reads only the platform D1 to resolve a host; each tenant Worker talks to whatever store its own bindings point at (own D1, a shared DB, or PlanetScale). Three different data stores can be in play:

StoreLives inOwned byPurpose
Platform D1one shared DBdispatchercustom_domains + proxy_routes — how the dispatcher resolves hosts
Per-tenant D1optionaleach tenant Workerthe tenant's own users, clients, sessions, etc. — full isolation
Shared tenant DBone shared DBall tenant Workersshared users, clients, etc. — only data partition is tenant_id

You can mix: tenant A uses its own fresh D1, tenant B uses the shared tenant DB, tenant C uses a PlanetScale URL. Each tenant Worker's wrangler bindings dictate what it talks to; the dispatcher only cares about the platform D1.

Request flow

A user request never reaches a tenant Worker directly — it always lands on the dispatcher first, which resolves the host and forwards into the namespace:

The dispatcher caches host → tenant resolution (~30s by default), so a newly inserted custom_domains row may 404 until the cache expires — see Troubleshooting.

Prerequisites

  • A Cloudflare account on the Workers for Platforms plan (dispatch namespaces require this)
  • Wrangler CLI v3.50+
  • Node.js 20+

Verify your account has the WFP plan:

bash
npx wrangler dispatch-namespace create test-namespace-delete-me
npx wrangler dispatch-namespace delete test-namespace-delete-me

If the first command 403s, your account doesn't have WFP enabled — talk to your Cloudflare account team.

Step 1: Set up the dispatcher

1.1 Scaffold

bash
npm create authhero@latest auth-dispatcher -- \
  --template=cloudflare-wfp-dispatcher
cd auth-dispatcher
npm install

This produces a thin Worker built on @authhero/proxy that uses the dispatch_namespace handler. Key files:

  • src/index.ts — Worker entrypoint; wraps createProxyDataAdapter with a default dispatch fallback
  • src/types.tsEnv with AUTH_DB: D1Database and DISPATCHER: DispatchNamespace
  • wrangler.toml — declares the dispatch namespace binding and the platform D1

1.2 Create the dispatch namespace

bash
npx wrangler dispatch-namespace create authhero-tenants

The name you pick here must match the namespace = "..." field in wrangler.toml and the --dispatch-namespace=... flag you'll use when deploying tenant Workers.

1.3 Create the platform D1

bash
npx wrangler d1 create authhero-platform-db

The output gives you a database_id. Copy it into wrangler.local.toml (gitignored):

toml
[[d1_databases]]
binding = "AUTH_DB"
database_name = "authhero-platform-db"
database_id = "abc123def456..."   # paste here
migrations_dir = "node_modules/@authhero/drizzle/drizzle"

Why a separate "platform" D1

The dispatcher only needs tenants, custom_domains, and proxy_routes rows. Keeping it on its own D1 means tenant Workers can't accidentally corrupt the routing table, and per-tenant deploys can't trip dispatcher rate limits.

You can point the dispatcher at the same shared DB the tenants use — the schema is a superset — but separation is cleaner if you have the operational budget for two D1s.

1.4 Apply the schema

The dispatcher template ships migrations from @authhero/drizzle:

bash
npm run db:migrate:remote

That runs wrangler d1 migrations apply AUTH_DB --remote --config wrangler.local.toml against the platform D1. After it finishes you should see the tenants, custom_domains, and proxy_routes tables (plus the rest of the authhero schema — harmless on the platform D1).

For local development:

bash
npm run db:migrate:local      # apply to local sqlite emulation of D1

1.5 Deploy the dispatcher

bash
npm run deploy

The dispatcher is now live at auth-dispatcher.<your-account>.workers.dev but it has no tenants and won't resolve any hosts yet. You can sanity-check it:

bash
curl -i https://auth-dispatcher.<your-account>.workers.dev
# HTTP/1.1 404
# Unknown host

That's the expected response — no custom_domains row matches auth-dispatcher.<your-account>.workers.dev.

Step 2: Pick a tenant storage model

Each tenant Worker needs a database. You have three reasonable options; the choice affects the tenant's storage_kind field (see Tenant lifecycle fields below) and the bindings on that tenant's wrangler.toml.

Option A: own D1 (full isolation)

Each tenant gets its own freshly-created D1, seeded with a fresh schema and an initial admin user. Best isolation, highest setup cost.

  • Pros: complete data isolation; per-tenant backup/restore; per-tenant migration scheduling
  • Cons: hits Cloudflare's D1-per-account limits; CI / provisioning has more moving parts
  • storage_kind: own_d1
toml
# tenant-acme/wrangler.toml
[[d1_databases]]
binding = "AUTH_DB"
database_name = "authhero-tenant-acme"
database_id = "xyz789..."

Option B: existing D1 (shared per-batch)

A pre-created D1 (e.g. the platform D1, or one shared D1 per region) hosts data for many tenants, partitioned by tenant_id in every query. Same model the single-Worker deploy uses.

  • Pros: one D1 to operate; simple migrations
  • Cons: no data-layer isolation; one tenant's noisy queries impact all
  • storage_kind: existing_d1
toml
# tenant-acme/wrangler.toml
[[d1_databases]]
binding = "AUTH_DB"
database_name = "authhero-platform-db"
database_id = "abc123..."  # same as the dispatcher's D1

Option C: PlanetScale (cross-region MySQL)

Tenant Workers connect to a shared PlanetScale URL via a secret binding instead of a D1. Good when D1's per-region replication isn't enough.

  • Pros: cross-region writes; mature MySQL tooling
  • Cons: extra network hops from the Worker; no data isolation
  • storage_kind: shared_planetscale
toml
# tenant-acme/wrangler.toml
# No D1 binding. PlanetScale URL is a secret instead.
bash
echo "mysql://..." | wrangler secret put --name=tenant-acme-auth PLANETSCALE_URL

Pick early

The storage decision shapes the bundle you upload (the bindings in the Worker's wrangler.toml differ). It's far easier to pick once per tenant than to migrate later.

Step 3: Onboard a tenant

For each publisher, four discrete steps. We'll use acme as the example tenant id and auth.acme.com as their domain.

3.1 Create the tenant row + custom domain in the platform D1

Either via the dispatcher's management API (if you've deployed an admin instance of authhero) or by direct SQL:

bash
curl -X POST https://admin.authhero.example.com/api/v2/tenants \
  -H "Authorization: Bearer $CONTROL_PLANE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "acme",
    "friendly_name": "Acme Inc",
    "audience": "https://auth.acme.com/",
    "sender_name": "Acme",
    "sender_email": "[email protected]",
    "deployment_type": "wfp",
    "storage_kind": "own_d1",
    "bundle_configuration": "authhero-drizzle-d1",
    "worker_version": "v1.0.0"
  }'

curl -X POST https://admin.authhero.example.com/api/v2/custom-domains \
  -H "Authorization: Bearer $CONTROL_PLANE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "acme",
    "domain": "auth.acme.com",
    "type": "auth0_managed_certs"
  }'
sql
-- Run against the platform D1 via `wrangler d1 execute authhero-platform-db --remote`
INSERT INTO tenants (id, friendly_name, audience, sender_name, sender_email,
                     deployment_type, storage_kind, bundle_configuration,
                     worker_version, provisioning_state, created_at, updated_at)
VALUES ('acme', 'Acme Inc', 'https://auth.acme.com/', 'Acme', '[email protected]',
        'wfp', 'own_d1', 'authhero-drizzle-d1', 'v1.0.0', 'pending',
        '2026-06-06T00:00:00Z', '2026-06-06T00:00:00Z');

INSERT INTO custom_domains (id, tenant_id, domain, type, created_at, updated_at)
VALUES ('cd-acme', 'acme', 'auth.acme.com', 'auth0_managed_certs',
        '2026-06-06T00:00:00Z', '2026-06-06T00:00:00Z');

control-plane scope

The POST /api/v2/tenants endpoint requires the create:tenants scope and must be called against a control-plane tenant (see Multi-Tenancy). Use a service-account token, never a tenant token.

direct SQL does not register the hostname

The management-API path above goes through the control plane's Cloudflare adapter, which registers the hostname in the CF-for-SaaS zone. The direct-SQL path only writes a row — Cloudflare never hears about the domain and it will not route, no matter how the DNS is pointed. Use direct SQL only for a domain you have already registered in the zone by hand.

platform subdomains need no custom_domains row

The custom_domains entry above is for the tenant's branded domain (auth.acme.com). The tenant's platform subdomain (acme.token.example.com) routes without one: wrap the control plane's tenants adapter with wrapTenantsAdapterWithWfpKvPublish and its dispatch route is derived from the tenant row and published to KV automatically when provisioning completes — see Proxy → Deployment topologies → WFP tenant subdomains.

3.2 Provision the tenant's data store (only if storage_kind = "own_d1")

bash
# Create the per-tenant D1
npx wrangler d1 create authhero-tenant-acme

# Apply the schema bundle
npx wrangler d1 migrations apply authhero-tenant-acme --remote \
  --migrations-dir node_modules/@authhero/drizzle/drizzle

Then record the database_id Cloudflare returned:

sql
UPDATE tenants SET d1_database_id = 'xyz789...' WHERE id = 'acme';

One-shot schema apply

Running wrangler d1 migrations apply does ~40 sequential HTTP calls to D1 (one per migration), which takes 8-20 seconds. If you need it faster — for programmatic provisioning from a Worker — bundle all migrations into a single SQL file and POST once to /accounts/:id/d1/database/:dbid/query. D1's query endpoint accepts multi-statement bodies up to ~100KB; the current drizzle bundle is ~916 lines and fits comfortably. The API-driven provisioner (see API-driven provisioning) applies migrations through this HTTP endpoint rather than wrangler.

For Options B/C, skip this step — the data store already exists.

3.3 Seed initial data

The tenant Worker needs at least one admin user, signing keys, and the management API resource server. The seed() helper exported from authhero is idempotent and does this in one call:

typescript
import { seed } from "authhero";
import { drizzle } from "drizzle-orm/d1";
import createAdapters from "@authhero/drizzle";

// Run once against the tenant's D1
const db = drizzle(env.AUTH_DB);
const data = createAdapters(db);

await seed({
  data,
  tenantId: "acme",
  adminEmail: "[email protected]",
});

Easiest way to invoke this: ship a one-shot Worker route in the tenant template (POST /__seed) gated behind a setup token, or run it locally with wrangler dev against the remote D1.

3.4 Deploy the tenant Worker into the dispatch namespace

Scaffold a tenant Worker from the cloudflare template, edit its wrangler.toml to bind to the right data store (see Step 2), then deploy:

bash
cd tenant-acme/
wrangler deploy \
  --dispatch-namespace=authhero-tenants \
  --name=tenant-acme-auth

The script name must match the dispatcher's template — by default tenant-<tenant_id>-auth. Override the template globally via the SCRIPT_NAME_TEMPLATE env var on the dispatcher (supports {tenant_id}, {custom_domain_id}, {domain}, {host} placeholders).

After a successful deploy, mark the tenant ready:

sql
UPDATE tenants
SET provisioning_state = 'ready',
    provisioning_state_changed_at = '2026-06-06T00:00:00Z',
    worker_script_name = 'tenant-acme-auth'
WHERE id = 'acme';

3.5 Wire secrets

The tenant Worker needs at least ENCRYPTION_KEY (for at-rest encryption of credential fields) and any provider API keys it uses:

bash
wrangler secret put --name=tenant-acme-auth ENCRYPTION_KEY
wrangler secret put --name=tenant-acme-auth EMAIL_API_KEY
# ... etc.

Generate ENCRYPTION_KEY per tenant — don't share it across the namespace. See Encryption at Rest.

If tenants will register their own custom domains through their management API (rather than you creating them centrally, as in 3.1), the tenant Worker also needs to know where the control plane is:

toml
# tenant-acme/wrangler.toml
[vars]
CONTROL_PLANE_URL = "https://auth.example.com"

# Optional but recommended — keeps the call inside Cloudflare instead of
# looping out over the public edge and back in through the dispatcher.
[[services]]
binding = "CONTROL_PLANE"
service = "authhero-control-plane"

A tenant Worker cannot register a custom hostname itself: that needs Cloudflare account credentials, which by design live only on the control plane, and a hostname can only be claimed by one tenant, which only the control plane can see. So POST /api/v2/custom-domains on a tenant Worker writes through the control plane and mirrors the result into the tenant's own D1. Without CONTROL_PLANE_URL the tenant Worker refuses custom-domain writes (501) rather than storing a row Cloudflare never hears about; reads keep working. See Custom domains: the control plane is authoritative.

3.6 Point the tenant's custom domain at the dispatcher

In the Cloudflare Dashboard:

  1. Workers & Pages → dispatcher Worker → Triggers → Add Custom Domain → auth.acme.com
  2. Cloudflare provisions the TLS certificate and points DNS

Or in wrangler.toml:

toml
[[routes]]
pattern = "auth.acme.com/*"
zone_name = "acme.com"

3.7 Verify

bash
curl https://auth.acme.com/.well-known/openid-configuration

Expected: a JWKS issuer document with "issuer": "https://auth.acme.com/". If you get Unknown host (404), the custom_domains row isn't in place or the dispatcher hasn't picked it up yet (it caches host resolutions for 30s by default — see the proxy cache config).

Per-tenant routing customization

The dispatcher synthesizes a default catch-all dispatch route when a host has no explicit proxy_routes rows. The default routes everything to the tenant's namespace script.

To customize — add CORS, headers, intercept a path, mix the tenant Worker with a separate API — insert proxy_routes rows for that custom_domain_id. Sorted by priority ascending; first match wins.

Example: serve a static /healthz and apply CORS to the rest:

sql
INSERT INTO proxy_routes (id, tenant_id, custom_domain_id, priority, match, handlers, created_at, updated_at)
VALUES (
  'route-acme-healthz', 'acme', 'cd-acme', 100,
  '{"path": "/healthz"}',
  '[{"type": "static", "options": {"status": 200, "body": "ok"}}]',
  '2026-06-06T00:00:00Z', '2026-06-06T00:00:00Z'
);
INSERT INTO proxy_routes (id, tenant_id, custom_domain_id, priority, match, handlers, created_at, updated_at)
VALUES (
  'route-acme-default', 'acme', 'cd-acme', 1000,
  '{"path": "/*"}',
  '[
    {"type": "cors", "options": {"origins": ["https://app.acme.com"]}},
    {"type": "dispatch_namespace", "options": {"binding": "DISPATCHER", "script_name": "tenant-acme-auth"}}
  ]',
  '2026-06-06T00:00:00Z', '2026-06-06T00:00:00Z'
);

See the proxy handler reference for the full list (cors, headers, basic_auth, redirect, rewrite_location, http, dispatch_namespace, service_binding, static, cache).

Control-plane admin tokens

The control plane administers tenants through the same management API the tenant Workers expose. When an admin request is forwarded into a tenant Worker, the token presents a problem worth understanding.

The control plane mints admin tokens with its own issuer — e.g. iss = https://controlplane.token.example.com/, tenant_id: "controlplane", org_name: "acme". That request is dispatched to tenant-acme-auth, whose env.ISSUER is the per-tenant value https://auth.acme.com/. The signature verifies fine (the tenant Worker fetches the control plane's JWKS), but two checks would reject it out of the box:

  1. Issuer checkpayload.iss (control plane) ≠ this Worker's issuer.
  2. Audience check — the management API requires the urn:authhero:management audience, and the token may carry a per-domain audience instead.

authhero never hardcodes or derives an issuer. The host app widens the accepted set with two resolvers, both of which receive the token's tenant_id and default to the strict single-value check when unset:

typescript
import { init } from "authhero";

const { app } = init({
  dataAdapter,

  // Accept the control-plane issuer in addition to this Worker's own ISSUER.
  // Return [] to refuse — e.g. for tokens that shouldn't cross from the
  // control plane.
  additionalIssuers: ({ tenant_id }) =>
    tenant_id ? ["https://controlplane.token.example.com/"] : [],

  // Accept the control-plane / per-tenant audience alongside the built-in
  // urn:authhero:management.
  additionalManagementAudiences: ({ tenant_id }) =>
    tenant_id
      ? [
          "https://controlplane.token.example.com/v2/api/",
          `https://${tenant_id}.token.example.com/v2/api/`,
        ]
      : ["https://controlplane.token.example.com/v2/api/"],
});

The cross-tenant hop itself (a token for tenant controlplane operating on tenant acme) is gated separately: it's allowed only when the token's tenant_id equals the deployment's configured controlPlaneTenantId. Any other tenant-to-tenant hop is rejected with 403 Cross-tenant management requires a control-plane token. See Management API Security for that guard.

Scope the resolvers

These resolvers are purely additive and you own the scoping. Don't return a broad list unconditionally — key the accepted issuers/audiences off tenant_id (or other token context) so only genuine control-plane tokens are widened, and return [] otherwise.

Tenant lifecycle fields

Every tenant row carries metadata that describes how the tenant runs and where its data lives. These fields drive the dispatcher's routing and the provisioning flow.

FieldTypePurpose
deployment_type"shared" | "wfp" (default: "shared")Whether the tenant runs on the single-Worker deploy or in a dispatch namespace
provisioning_state"pending" | "ready" | "failed" (default: "ready")Lifecycle of the WFP setup. Shared tenants are always ready
provisioning_errorstring?Human-readable failure reason when state is failed
provisioning_state_changed_atISO timestampWhen the state last changed — useful for ops dashboards
bundle_configuratione.g. "authhero-drizzle-d1"Identifies which authhero build variant the tenant runs
worker_versione.g. "v1.2.3"Pins the release within that configuration
worker_script_namee.g. "tenant-acme-auth"Script name in the dispatch namespace. Defaults to tenant-<id>-auth
storage_kind"own_d1" | "existing_d1" | "shared_planetscale"Which storage option (A/B/C from Step 2) the tenant uses
d1_database_idstringThe actual D1 id when storage_kind is own_d1 or existing_d1

Existing shared tenants (created before these fields existed) keep working — deployment_type defaults to "shared" and provisioning_state defaults to "ready" at the database level. No backfill needed.

API-driven provisioning

The manual flow in Step 3 always works and is the easiest way to onboard your first tenant. For programmatic onboarding, @authhero/cloudflare-adapter ships a WFP provisioner that performs the same steps through the Cloudflare API:

typescript
import {
  createCloudflareWfpD1Provisioner,
  createWfpTenantProvisioningHook,
} from "@authhero/cloudflare-adapter";
import migration0000 from "@authhero/drizzle/drizzle/sqlite/0000_initial.sql?raw";

const provisioner = createCloudflareWfpD1Provisioner({
  // Cloudflare API credentials, dispatch namespace, the worker bundle,
  // migrations (imported as raw SQL and bundled into the control plane),
  // and a per-tenant secrets resolver — see the type for the full options.
  migrations: [{ name: "0000_initial.sql", sql: migration0000 }],
  // ...
});

const hook = createWfpTenantProvisioningHook({ provisioner, tenants });

The provisioner creates a D1 (for storage_kind: "own_d1"), applies the migration bundle through the D1 HTTP query endpoint, uploads the worker script to the dispatch namespace with its bindings and secrets, and maintains provisioning_state / provisioning_error on the tenant row. Migration bundles ship inside the control-plane worker itself (via ?raw SQL imports) — no external bundle registry is required.

The hook exposes three entry points:

MethodPurpose
onProvision(tenantId)Full first-time provisioning of a deployment_type: "wfp" tenant
onDeprovision(tenantId)Tear down the tenant's script (and optionally its D1)
onUpgrade(tenantId)Re-provision an existing tenant onto the current bundle + migrations; backs POST /api/v2/tenants/{id}/redeploy when wired via the tenantUpgrade option of init()

Clients observe provisioning asynchronously: POST /api/v2/tenants returns with provisioning_state: "pending" and callers poll GET /api/v2/tenants/:id (or the operations history) until the state transitions to ready or failed.

Durable provisioning with Cloudflare Workflows

For provisioning that survives engine crashes and records every step, wire the tenant-operations executor: each operation is journaled in the tenant_operations / tenant_operation_events tables, executed as a Cloudflare Workflow with durable steps (create-database → apply-migrations → upload-script → upload-secrets → seed-defaults → verify), and exposed via GET /api/v2/tenants/{id}/operations. See Tenant Operations for the data model, executor wiring, and reconciliation pattern, and Tenant Lifecycle for the surrounding create/update/delete hooks.

Later phases — fleet rollouts (waves + canary) and D1 Time Travel backups — are tracked in issue #1026.

Trade-offs vs. single Worker

Single WorkerWFP per-tenant
Operational complexityLowHigh (deploy per tenant)
Isolation between tenantsSoft (logical, via tenant_id)Hard (separate isolates per script)
Per-tenant code customizationNot supportedNative (different bundle per tenant)
Per-tenant resource limitsNot supportedPer-script CPU/subrequest caps
Cold-start costOne WorkerOne per tenant (mitigated by namespace placement)
CostWorkers Free / Paid planWorkers for Platforms plan ($25/mo + per-script costs)
Data isolationLogical onlyOptional (own_d1)
Maximum tenant count10,000s on one WorkerLimited by namespace script count (~10k typical)

Limitations

  • Local dev cannot emulate the namespace. wrangler dev runs the dispatcher locally, but env.DISPATCHER.get(...).fetch(...) only resolves against a real namespace in your Cloudflare account. Use npm run dev:remote to test end-to-end.
  • Per-tenant D1 limits. Cloudflare imposes a maximum number of D1 databases per account. If you choose own_d1 and onboard hundreds of tenants, you'll hit this — pick existing_d1 or PlanetScale for the long tail.
  • Bindings propagate per script. Each tenant Worker's wrangler.toml declares its own bindings; the dispatcher's bindings don't carry through. Codify the per-tenant wrangler.toml in your deploy pipeline. When provisioning through createCloudflareWfpD1Provisioner, pass an extraBindings array to attach additional bindings (beyond the default AUTH_DB and CONTROL_PLANE_BASE_URL) — including a service binding to a shared upstream Worker.
  • Secrets distribution. ENCRYPTION_KEY and other secrets must be set per-script via wrangler secret put --name=tenant-<id>-auth …. Plan to automate this (and rotation) in your deploy tooling.
  • Dispatch namespace cache. The dispatcher caches host resolution for 30s by default. After inserting a new custom_domains row, the first request may still 404 until the cache expires. Configure a shorter negativeTtlMs (see proxy cache options) if you onboard tenants frequently.

Troubleshooting

Unknown host 404 from the dispatcher

The hostname isn't in custom_domains for any tenant. Check:

bash
wrangler d1 execute authhero-platform-db --remote \
  --command "SELECT * FROM custom_domains WHERE domain = 'auth.acme.com'"

If the row exists, the dispatcher may be serving a cached negative result. Wait 30s or restart the Worker (wrangler deploy no-op).

Worker not found in namespace

The dispatcher resolved the host but env.DISPATCHER.get('tenant-acme-auth') 404s. Verify:

bash
wrangler dispatch-namespace list authhero-tenants

Expected output should include tenant-acme-auth. If not, re-run wrangler deploy --dispatch-namespace=authhero-tenants --name=tenant-acme-auth.

Common causes:

  • worker_script_name on the tenants row doesn't match what's actually deployed
  • The dispatcher's SCRIPT_NAME_TEMPLATE was changed and existing rows still point to the old name

ENCRYPTION_KEY not set errors from the tenant Worker

Each tenant Worker needs its own secret:

bash
wrangler secret put --name=tenant-acme-auth ENCRYPTION_KEY

Secrets do not propagate from the dispatcher to namespace scripts.

D1 migrations time out

Applying ~40 migrations sequentially can hit Cloudflare's per-request limits when run from a Worker. Workarounds:

  • Run wrangler d1 migrations apply from your dev machine, not from a Worker
  • Use the one-shot bundle approach described in Step 3.2: concatenate all migrations into a single SQL file and POST once to /accounts/:id/d1/database/:dbid/query

Next Steps

Dual-licensed: AGPL-3.0-only or commercial license.