Skip to main content

Multi-Tenant D1

Most SaaS apps put every tenant's data in one big shared database with a tenant_id column on every table. That's fine until somebody forgets a WHERE tenant_id = ? clause in a hand-written query and one customer sees another's data. We chose the opposite extreme: every tenant gets a physically separate Cloudflare D1 database, and a tiny central database is the only thing that knows how to route to them.

Two layers

Control Plane D1
1 instance

A single, system-wide database that holds the data Cloudflare needs to authenticate a request and route it to the right tenant. Nothing customer-domain ever lives here.

organizations — id, name, slug, plan, d1_binding, created_at
users — id, email, password_hash, mfa_secret, primary_org_id
org_memberships — user_id, org_id, role, joined_at
billing_subscriptions — org_id, stripe_id, plan, seats, status
api_keys — token_hash, org_id, scopes, expires_at
audit_log — org-level events for compliance
Per-Tenant D1
1 per company

A dedicated database for every paying customer. Holds every contact, project, invoice, field value, attachment reference, and audit record that company has ever created.

records — the base table (see Data Model)
field_definitions — tenant-specific schema
field_values_* — text · int · real · bool · datetime · ref · json
changes — append-only sync log (see Offline Sync)
views — saved table/kanban/gantt/card configs
r2_pointers — file refs into the tenant's R2 namespace

How a request finds its tenant

Every request to the CRM is mediated by a Cloudflare Edge Worker. The Worker is responsible for one job at this stage: convert a JWT into a D1 binding for the right tenant, then hand the request off to the handler that does the real work.

Step 1
Request arrives
Mobile or web sends an HTTPS request with a Bearer JWT in the Authorization header.
Step 2
JWT verify
Worker validates the signature, expiry, and claims. Extracts user_id and active org_id from the payload.
Step 3
Control-plane lookup
Query Control Plane D1 for org_id → d1_binding name. Cached at the edge for 60s.
Step 4
Bind tenant D1
Worker resolves the binding name to the actual D1 database object via env.D1_TENANTS.get(binding).
Step 5
Handler runs
The business logic gets a request context with one tenant D1 and zero awareness of any other tenant.

The key property: the handler code that reads or writes business data only has access to one D1 binding. There is no tenant_id column to filter on, because there's no other tenant in the query scope. A bug that forgets to filter is impossible.


The provisioning flow

When a new company signs up, the control plane spins up their D1.

StepActionWhat happens under the hood
1Signup form submittedCompany name, owner email, primary phone, industry. Stored in a pending_organizations table while email is being verified.
2Email verifiedUser confirms via OTP or magic link. Pending row promoted to organizations.
3D1 database createdWorker calls the Cloudflare D1 management API to provision a new database named org_<slug>_<short_uuid>. Provisioning is async; we poll until ready.
4Schema appliedThe standard tenant schema is loaded — records, field_definitions, field_values_*, changes, views, r2_pointers — along with the industry-specific field bundle for the company's trade.
5R2 namespace createdA new R2 bucket prefix is allocated for the tenant's files. The bucket itself is shared; the prefix scopes the tenant's keys.
6Binding registeredThe D1 binding name is written to organizations.d1_binding in the control plane. The Worker's D1_TENANTS dynamic binding picks it up on the next deploy or refresh.
7Owner user provisionedThe signup user is inserted into the control-plane users table and added to org_memberships with role=owner.
8Welcome state seededA small set of demo records (sample contact, sample project) is inserted into the new tenant D1 so the dashboard isn't empty on first login.

What this buys us

Hard isolation
A bug in a tenant's query, a vulnerability in an endpoint, a misconfigured permission — none of them can leak data across tenants. The blast radius of a query bug is exactly one company.
One-click export
A tenant exports their data: we dump their D1. No filtering, no joining across tenant_id boundaries, no risk of accidentally including another company's row.
One-click delete
GDPR right-to-erasure for an entire organization = drop their D1. Compare with the months-long cross-table delete sprees on shared-schema systems.
Per-tenant restore
Cloudflare D1 backs up every database independently. Restoring one tenant to a point in time doesn't roll back anyone else.
Per-tenant scaling
A huge tenant doesn't push the small tenants out of cache. Each D1 has its own connection budget and query plan cache.
Schema drift is safe
Custom fields are tenant-local by definition. Schema migrations roll out tenant-by-tenant on their next sync; one tenant being behind doesn't affect anyone else.
Audit trail per tenant
All audit logging is local to the tenant D1. A compliance request is a single-D1 query, not a cross-table scan with a tenant filter.
Easy data-residency story
Cloudflare can pin a D1 to a specific region. EU customers' D1s live in EU; US customers' D1s live in US. Same code path, different physical location.

Cross-tenant operations we still need

Almost everything in the system is tenant-scoped — that's the whole point. A few operations genuinely cross tenants, and we handle each one explicitly.

User belongs to multiple orgsusers live in the control plane; org_memberships is the join table. A user switching context re-issues a JWT with a different active_org_id claim.
Admin/support read-only accessA support staff JWT carries a special claim that lets them open any tenant D1 in read-only mode. Every read is logged to a separate cross-tenant audit table.
Aggregate reporting (internal)A nightly job reads non-sensitive metrics from every tenant D1 — counts, billing-relevant usage — and writes them to an analytics database. Customer data never crosses the wall.
Templates and marketplaceIndustry-specific field bundles and workflow templates live in the control plane. Tenants install them; the install copies the definitions into the tenant D1.
Federation between two tenantsRare. Handled as an explicit, audited API call where one tenant publishes a record and another consumes it via webhook. No shared tables.

Continue to Headless API for how every client — mobile, web, customer portal, and any future consumer — reaches these per-tenant databases through a single unified contract.