Single-Product Multi-Tenant Architecture
Audience: Consumers building one multi-tenant product with Tamer. This is the simplified starting point — it grows into a full multi-product platform (multiple dispatch namespaces under wfp.namespaces) if the business needs multiple products.
What you get
One repo, one product, multi-tenant on Cloudflare Workers for Platforms:
- Per-tenant D1 database shards (system, app, history — whatever you declare per service)
- Per-tenant Worker scripts in a dispatch namespace — one script per declared service (runtime isolation)
- One API worker (auth, admin, user management, dispatch routing)
- One SPA worker (frontend with build step)
- Per-env deploys (dev, prod, ephemeral PR previews)
Architecture
flowchart TB
subgraph browser["Browser"]
USER[User]
end
subgraph cloudflare["Cloudflare (one account)"]
subgraph static["Static Assets"]
SPA[SPA Worker<br/>myapp-web-{env}]
end
subgraph api["API Worker"]
APIW[myapp-api-{env}<br/>auth · admin · dispatch routing<br/>env.DISPATCHER · env.APP_DB]
end
subgraph wfp["WFP Dispatch Namespace"]
NS["myapp-{env}"]
ACME["tenant-acme<br/>script"]
GLOBEX["tenant-globex<br/>script"]
NS --> ACME
NS --> GLOBEX
end
subgraph data["Per-tenant D1 shards"]
ACME_SYS[(db_system_myapp_acme_{env})]
ACME_APP[(db_app_myapp_acme_{env})]
GLOBEX_SYS[(db_system_myapp_globex_{env})]
GLOBEX_APP[(db_app_myapp_globex_{env})]
end
subgraph tamer["Tamer state"]
STATE[(tamer-state D1)]
SECRETS[(tamer-secrets D1)]
ARTIFACTS[(tamer-artifacts R2)]
end
end
USER -->|"GET myapp.com/*"| SPA
USER -->|"GET api.myapp.com/{ws}/*"| APIW
APIW -->|"DISPATCHER.get(tenant-{ws})"| NS
ACME --> ACME_SYS
ACME --> ACME_APP
GLOBEX --> GLOBEX_SYS
GLOBEX --> GLOBEX_APP
APIW -.->|"admin reads"| APPDB[(APP_DB)]What you DON'T need
| Full platform | Single product |
|---|---|
| 5 repos (platform, portal, internal, product-*) | 1 repo |
| platform-service (control plane RPC) | Not needed — Tamer does provisioning |
| provision-workflow (Cloudflare Workflow) | Not needed — wfp tenant provision CLI |
| Separate dispatch Worker | API Worker holds the DISPATCHER binding |
| portal-api per-product fanout | One API Worker is the whole API |
| Contract packages (cross-repo types) | Everything in one repo |
| Stub platform | You're already single-product |
Request flow
sequenceDiagram
participant B as Browser
participant SPA as SPA Worker
participant API as API Worker
participant NS as Dispatch Namespace
participant TW as Tenant Worker (tenant-acme)
participant DB as D1 shards
Note over B,SPA: UI — static assets, no Worker hop
B->>SPA: GET myapp.com/dashboard
SPA-->>B: HTML + JS bundle
Note over B,TW: API — workspace path
B->>API: GET api.myapp.com/acme/v1/tasks
Note over API: parse workspace = "acme"
API->>NS: DISPATCHER.get("tenant-acme")
NS-->>API: tenant script handle
API->>TW: forward("/v1/tasks")
TW->>DB: query DB_SYSTEM, DB_APP
DB-->>TW: rows
TW-->>API: JSON response
API-->>B: 200 OKRepo layout
my-app/
├── tamer/
│ └── project.config.ts # single config — declares everything
├── apps/
│ ├── api/ # main API Worker (auth, admin, DISPATCHER binding)
│ │ ├── src/index.ts
│ │ └── migrations/ # APP_DB migrations
│ ├── tenant/ # tenant Worker templates (uploaded to WFP namespace)
│ │ └── src/index.ts
│ └── spa/ # frontend (Vite build)
│ ├── src/
│ └── vite.config.ts
├── packages/
│ └── shared/ # contracts, types, Zod schemas shared across apps
│ └── src/index.ts
├── db/
│ ├── system/migrations/ # system shard schema
│ └── app/migrations/ # app shard schema
├── package.json
└── .env # CF credentials onlyConfig
Split config across files: top-level project config imports worker definitions, which import their env overrides.
tamer/
├── project.config.ts # top-level: tenant, naming, workers map, outputs
├── workers/
│ ├── api/
│ │ ├── base.ts # API worker: resources, secrets, vars, routes
│ │ └── env/
│ │ ├── dev.ts # dev-specific overrides
│ │ └── prod.ts # prod-specific overrides
│ └── spa/
│ ├── base.ts # SPA worker: assets, build, vars
│ └── env/
│ ├── dev.ts
│ └── prod.tsProject config
// tamer/project.config.ts
import { cf, defineConfig } from "@dragonmastery/tamer";
import { apiWorker } from "./workers/api/base";
import { spaWorker } from "./workers/spa/base";
export default defineConfig({
stack: "myapp",
account_id: "your-account-id",
compatibility_date: "2025-05-19",
// Workers for Platforms: one dispatch namespace per product-env.
// Default namespace name is `{product}-{env}` (e.g. `myapp-dev`).
// Each product declares per-service tenant Worker templates; each
// workspace gets one script per service (e.g. `tenant-acme`).
wfp: {
ephemeralEnvPattern: "^pr-",
tenantDirectory: {
worker: "api",
shardGroup: "directory",
service: "tenant",
},
namespaces: {
myapp: {
workers: {
tenant: {
main: "apps/tenant/src/index.ts",
shardGroups: [
{ name: "primary", binding: "DB", migrationsDir: "db/primary/migrations" },
],
d1: [
{ logicalName: "system", binding: "DB_SYSTEM", migrationsDir: "db/system/migrations" },
],
vars: { APP_NAME: "MyApp" },
// Secret names read from the vault and pushed to the dispatch script
secrets: ["JWT_SECRET", "STRIPE_API_KEY"],
},
},
},
},
},
workers: {
api: apiWorker,
spa: spaWorker,
},
outputs: {
api_worker_name: cf.worker("api").name,
spa_worker_name: cf.worker("spa").name,
},
});API worker
// tamer/workers/api/base.ts
import { defineWorker } from "@dragonmastery/tamer";
import { apiDevEnv } from "./env/dev";
import { apiProdEnv } from "./env/prod";
const ZONE = "myapp.com";
export const apiWorker = defineWorker({
path: "apps/api",
main: "src/index.ts",
scriptName: "myapp-api",
workers_dev: false,
preview_urls: false,
observability: { enabled: true, head_sampling_rate: 1 },
resources: {
d1: [{
logicalName: "app-db",
type: "single",
binding: "APP_DB",
migrationsDir: "apps/api/migrations",
}],
},
secrets: {
required: ["STRIPE_API_KEY", "JWT_SECRET"],
},
vars: {
ENVIRONMENT: "local",
LOG_LEVEL: "debug",
},
tamerRoutes: [
{ host: "api.myapp.com", zone: ZONE, customDomain: true },
],
env: {
dev: apiDevEnv,
prod: apiProdEnv,
},
});// tamer/workers/api/env/dev.ts
import type { EnvOverride } from "@dragonmastery/tamer";
const ZONE = "myapp.com";
export const apiDevEnv = {
vars: {
ENVIRONMENT: "${tamer:env}",
LOG_LEVEL: "debug",
},
tamerRoutes: [
{ host: "api.myapp.com", zone: ZONE, customDomain: true },
],
} satisfies EnvOverride;// tamer/workers/api/env/prod.ts
import type { EnvOverride } from "@dragonmastery/tamer";
export const apiProdEnv = {
vars: {
ENVIRONMENT: "prod",
LOG_LEVEL: "info",
},
} satisfies EnvOverride;SPA worker
// tamer/workers/spa/base.ts
import { defineWorker } from "@dragonmastery/tamer";
import { spaDevEnv } from "./env/dev";
import { spaProdEnv } from "./env/prod";
const ZONE = "myapp.com";
export const spaWorker = defineWorker({
path: "apps/spa",
scriptName: "myapp-web",
assets: { directory: "dist", not_found_handling: "single-page-application" },
build: { command: "vite build" },
vars: {
VITE_API_URL: "http://127.0.0.1:8993/v1",
ENVIRONMENT: "local",
},
tamerRoutes: [
{ host: "myapp.com", zone: ZONE, customDomain: true },
],
env: {
dev: spaDevEnv,
prod: spaProdEnv,
},
});// tamer/workers/spa/env/dev.ts
import type { EnvOverride } from "@dragonmastery/tamer";
export const spaDevEnv = {
vars: {
VITE_API_URL: "https://${tamer:env}.api.myapp.com/v1",
ENVIRONMENT: "${tamer:env}",
},
} satisfies EnvOverride;API Worker dispatch routing
The API Worker parses the workspace from the URL path and forwards to the tenant's dispatch script:
// apps/api/src/index.ts
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
// Reserved paths (auth, admin, health) — handle directly
if (url.pathname.startsWith("/rpc") || url.pathname.startsWith("/auth")) {
return handleApi(req, env);
}
// Workspace path: /{workspace}/v1/...
const [, workspace, ...rest] = url.pathname.split("/");
if (!workspace) return new Response("Not found", { status: 404 });
// Route to the tenant's Worker in the dispatch namespace.
// Script name is always `{service}-{workspace}-{env}`.
const SERVICE = "tenant";
const scriptName = `${SERVICE}-${workspace}-${env.ENVIRONMENT}`;
const tenant = env.DISPATCHER.get(scriptName);
const tenantUrl = new URL("/" + rest.join("/"), url.origin);
return tenant.fetch(new Request(tenantUrl, req));
},
};
interface Env {
APP_DB: D1Database;
DISPATCHER: Fetcher;
ENVIRONMENT: string;
}Lifecycle
One-time setup
tamer bootstrap # creates shared D1/R2/secrets (once per account)
tamer apply --env dev # creates dispatch namespace + D1 + R2 resources
tamer deploy --env dev # builds SPA + deploys API + SPA workersProvision tenants
sequenceDiagram
participant Op as Operator / CI
participant T as Tamer CLI
participant CF as Cloudflare API
participant WR as Wrangler
participant NS as Dispatch namespace
participant D1 as D1 databases
Op->>T: wfp tenant provision --env dev --workspace acme
Note over T: resolves namespace = myapp-dev<br/>resolves script = tenant-acme (one per declared service)
T->>CF: create D1: db_system_myapp_acme_dev
T->>CF: create D1: db_app_myapp_acme_dev
T->>D1: migrations apply (system shard)
T->>D1: migrations apply (app shard)
T->>T: write temp wrangler.tenant.json (D1 bindings + vars + services + secrets)
T->>WR: wrangler deploy --dispatch-namespace myapp-dev (bundle TS, WASM, polyfills)
WR->>NS: upload bundled script "tenant-acme" with bindings
Note over NS: script has env.DB_SYSTEM, env.DB_APP
T-->>Op: ✓ ready — tenant-acme live in myapp-dev# Creates per-service D1 shards → migrates schema → bundles + deploys via wrangler
# with D1 bindings. `--namespace` is only required when more than one namespace is
# declared under wfp.namespaces — the namespace key IS the product identity.
tamer wfp tenant provision --env dev \
--workspace acme \
--main apps/tenant/src/index.ts
# List provisioned tenants
tamer wfp tenant list --env dev
# Show one tenant's details
tamer wfp tenant status --env dev --workspace acme
# Run migrations on an existing tenant's shards
tamer wfp tenant migrate --env dev --workspace acme
# Squash tenant migrations and wipe tenant D1(s) without destroying the tenant
tamer wfp tenant reset --env dev --workspace acme --kind d1
tamer wfp tenant reset --env dev --workspace acme --target d1:system
tamer wfp tenant reset --env dev --workspace acme --target shard_group:primary
# Local: generate wrangler.json + wrangler.vitest.json, then Miniflare D1
tamer wfp tenant wrangler --env local --workspace localdev
tamer wfp tenant migrate --env local --workspace localdev
tamer wfp tenant seed --env local --workspace localdev
tamer wfp tenant reset --env local --workspace localdev --kind d1
# bun run dev → wrangler.json; Vitest → wrangler.vitest.json
# Destroy a tenant (shards + script + state)
tamer wfp tenant destroy --env dev --workspace acme --forceCI workflow (dev)
- tamer secrets verify --env dev
- tamer apply --env dev
- tamer migrate --env dev
- tamer deploy --env devEphemeral PR previews
# No bootstrap needed — shared resources already exist
tamer secrets copy --from dev --to pr-42
tamer apply --env pr-42
tamer deploy --env pr-42
# → live at dev.myapp.com (or pr-42.myapp.com with route expansion)
# Cleanup when PR closes
tamer destroy --env pr-42 --confirm-env pr-42 --wipe-metadata --forceGarbage collection (scheduled)
# Hourly sweep — destroys ephemeral envs older than 72h
tamer env gc --max-age 72h --forceWhat the tenant Worker sees
After wfp tenant provision, each tenant service Worker is bundled by wrangler (TS compiled, WASM packaged, polyfills applied) and deployed to the dispatch namespace with these bindings:
export default {
async fetch(req: Request, env: Env): Promise<Response> {
// Shard group bindings (append-only — DB_000, DB_001, …)
const primary = env.DB_000;
// Per-tenant utility D1 (non-sharded)
const system = env.DB_SYSTEM;
const appName = env.APP_NAME;
return new Response("OK");
},
};
interface Env {
DB_000: D1Database;
DB_SYSTEM: D1Database;
APP_NAME: string;
}Each tenant workspace gets its own isolated set of D1 databases, one set per declared service. For product myapp, workspace acme, env dev, service tenant with a primary shard group and a system utility D1, the databases are db_primary_000_myapp_acme_dev and db_system_myapp_acme_dev. Workspace globex gets db_primary_000_myapp_globex_dev / db_system_myapp_globex_dev. Grow horizontal capacity with wfp tenant add-shard --group primary. The dispatch script is tenant-acme (and tenant-globex), both living in the myapp-dev namespace. No cross-tenant data access.
Growing to multi-product later
If the business adds a second product, the path is:
- Extract the dispatch routing into its own Worker (the
dispatchWorker from the full platform architecture) - Extract tenant lifecycle management into
platform-service(or keep usingwfp tenant provisionCLI — it works for both patterns) - Split the repo into
platform/,portal/, and per-product repos - Add cross-stack imports (
${tamer:import:platform.workspace_workers_namespace_name})
Tamer's infrastructure layer (dispatch namespaces, D1 shards, WFP script upload, bindings, migrations) is identical in both architectures. The difference is ownership boundaries (repos) and routing complexity (dispatch Worker vs direct API Worker).
Namespace keys are immutable identity
A namespace key (wfp.namespaces.<product>) is identity, not a label. It flows unchanged through the naming engine into four Cloudflare-name derivations, all of which are immutable on Cloudflare's side:
| Resource | Name | Example |
|---|---|---|
| dispatch namespace | {stack}-{product}-{env} | aat-tenant-dev |
| tenant state key | {product}:{workspace} | tenant:caa |
| shard-group D1 | db_{group}_{NNN}_{product}_{workspace}_{env} | db_user_000_tenant_caa_dev |
| per-tenant utility D1 | db_{logical}_{product}_{workspace}_{env} | db_app_setting_tenant_caa_dev |
| R2 block bucket | r2-{name}-{NNN}-{product}-{env} | r2-assets-000-tenant-dev |
Renaming a namespace key (e.g. aat → tenant) is therefore a full re-provision under a new identity, not an in-place edit. Cloudflare can't rename databases, buckets, or namespaces, and the old names embed the old key. Tamer handles the detection and cleanup; data migration (export/import) is the operator's job.
What happens if you rename a key
The old dispatch scripts, D1s, and R2 bucket persist in Cloudflare under the old key. Tamer detects them and can clean them up:
tamer syncrefills state from Cloudflare (the old tenant'sworkersrefs are re-derived from the deterministic CF names) and flags the old namespace's tenant with a__namespace__orphan marker. It also records the old dispatch namespace at the stack level so it shows as an orphan.tamer driftlists the orphaned tenant's resources inunrecordedInStateand any tenant-shaped CF resources matching no known tenant as ghosts.tamer wfp tenant destroy --namespace <old> --workspace <ws>destroys the renamed-away tenant: it deletes the old D1s/scripts by discovered identity (even when the state refs were lost) and drops the tenant entry.--namespaceaccepts a key no longer in config when a tenant in state still references it.tamer destroy --orphansthen removes the orphaned dispatch namespace itself.
Recovery workflow
# 1. Refill state from CF + flag the old namespace.
tamer sync --env dev
# 2. See the orphaned tenant + ghosts.
tamer drift --env dev
# 3a. One tenant under the old namespace:
tamer wfp tenant destroy --env dev --namespace aat --workspace caa
# 3b. OR every tenant still referencing the old namespace (bulk — no --workspace):
tamer wfp tenant destroy --env dev --namespace aat --orphans
# 4. Remove the orphaned dispatch namespace (stack-level orphan sweep).
tamer destroy --env dev --orphansR2 block buckets are multi-tenant; per-tenant destroy cleans that tenant's {tenantNumber}/ prefix, and the bucket itself is removed when the whole namespace is torn down.
Preventing silent drift in CI
tamer sync --strict-namespace-guard exits nonzero when tenant entries reference namespaces no longer declared in config — wire it into CI to catch a rename before ghosts accumulate. (D1/R2 data is never touched by the guard; it only fails the run.)
Comparison: what stays the same
| Feature | Single product | Multi-product |
|---|---|---|
| Dispatch namespace | ✓ same | ✓ same |
wfp tenant provision | ✓ same | ✓ same |
| D1 shard groups + utility D1s | ✓ same | ✓ same |
shardGroups + d1 config | ✓ same | ✓ same |
| Tenant migrations | ✓ same | ✓ same |
| Multi-env deploys | ✓ same | ✓ same |
| Ephemeral PR envs | ✓ same | ✓ same |
| Secrets vault | ✓ same | ✓ same |
${tamer:env} reference | ✓ same | ✓ same |
The infrastructure doesn't change. Only the application architecture above it.