Skip to content

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

mermaid
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 platformSingle 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 WorkerAPI Worker holds the DISPATCHER binding
portal-api per-product fanoutOne API Worker is the whole API
Contract packages (cross-repo types)Everything in one repo
Stub platformYou're already single-product

Request flow

mermaid
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 OK

Repo 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 only

Config

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.ts

Project config

ts
// 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

ts
// 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,
  },
});
ts
// 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;
ts
// 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

ts
// 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,
  },
});
ts
// 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:

ts
// 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

bash
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 workers

Provision tenants

mermaid
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
bash
# 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 --force

CI workflow (dev)

yaml
- tamer secrets verify --env dev
- tamer apply --env dev
- tamer migrate --env dev
- tamer deploy --env dev

Ephemeral PR previews

bash
# 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 --force

Garbage collection (scheduled)

bash
# Hourly sweep — destroys ephemeral envs older than 72h
tamer env gc --max-age 72h --force

What 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:

ts
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:

  1. Extract the dispatch routing into its own Worker (the dispatch Worker from the full platform architecture)
  2. Extract tenant lifecycle management into platform-service (or keep using wfp tenant provision CLI — it works for both patterns)
  3. Split the repo into platform/, portal/, and per-product repos
  4. 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:

ResourceNameExample
dispatch namespace{stack}-{product}-{env}aat-tenant-dev
tenant state key{product}:{workspace}tenant:caa
shard-group D1db_{group}_{NNN}_{product}_{workspace}_{env}db_user_000_tenant_caa_dev
per-tenant utility D1db_{logical}_{product}_{workspace}_{env}db_app_setting_tenant_caa_dev
R2 block bucketr2-{name}-{NNN}-{product}-{env}r2-assets-000-tenant-dev

Renaming a namespace key (e.g. aattenant) 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 sync refills state from Cloudflare (the old tenant's workers refs 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 drift lists the orphaned tenant's resources in unrecordedInState and 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. --namespace accepts a key no longer in config when a tenant in state still references it.
  • tamer destroy --orphans then removes the orphaned dispatch namespace itself.

Recovery workflow

bash
# 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 --orphans

R2 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

FeatureSingle productMulti-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.

Released under the Tamer Evaluation License.