How We Handle Multi-Tenancy on Cloudflare Workers (Without Losing Our Minds)
Building Nuvo Consent on Cloudflare Workers forced us to rethink multi-tenancy from scratch. Here are the real decisions we made about isolation, routing, and data - including the ones we got wrong.
Most articles about multi-tenancy describe a framework.
A clean diagram. A neat pattern. A decision tree that always points to the obvious answer.
Real products don’t work like that.
When we started building Nuvo Consent on Cloudflare Workers, we didn’t have a multi-tenancy strategy. We had a deadline, a few assumptions, and a model that turned out to be wrong twice before it was right.
It’s the set of actual decisions we made - including the ones we reversed.
If you’re building a SaaS product on Workers, or the tenant model you started with is beginning to feel uncomfortable, this is the article I wish we’d had.
Quick Answer
Here’s how multi-tenancy works in Nuvo Consent today:
- One Worker serves every tenant. There is no per-tenant deployment.
- A tenant is a workspace. That’s the unit of isolation everywhere in the system.
- Hot-path config (which domains belong to which workspace, banner settings) lives in KV, isolated by key prefix.
- The system of record (consent events, audit logs) lives in D1, isolated at the row level.
- Tenant context is resolved once, at the edge, from the incoming hostname - and then carried through the entire request.
None of this was our first design.
Why Traditional Multi-Tenancy Doesn’t Fit Workers
The standard playbook for multi-tenant SaaS is well understood.
One database. Every table gets a tenant_id column. Every query filters by it. You scale the database vertically until it hurts, then you shard.
It works. Millions of products run this way.
But it assumes something Workers quietly takes away: a single, central place where your code runs close to your database.
Workers run at the edge - your code executes in whichever data center is closest to the user. Put a traditional Postgres instance in one region behind that, and every request from the other side of the world pays a round trip across the planet before it can answer a question as simple as “what color is this banner?”
The latency you saved by running at the edge, you hand right back at the database.
There’s a second problem, and it’s worse than latency.
In the shared-table model, isolation is a promise. Every query must include WHERE tenant_id = ?. The day someone forgets - in an admin tool, in a migration script, in a hastily written report - one tenant sees another tenant’s data.
For most products that’s a bug.
For a consent and privacy product, it’s the exact failure we exist to prevent.
So before writing much code, we had to answer two questions Workers forced on us:
- What lives close to the user, and what lives in one consistent place?
- How do we make tenant isolation something the system enforces, not something a developer remembers?
The answer to the first is the KV-versus-D1 tradeoff.
KV is globally replicated and eventually consistent. Reads are fast everywhere. It’s perfect for data that’s read constantly and written rarely.
D1 is SQL. It’s strongly consistent and lives in a primary region. It’s right for relational, queryable, must-be-correct data.
Most of our multi-tenancy story is just deciding which tenant data belongs in which of those two.
What We Isolate, and What We Share
The instinct with multi-tenancy is to isolate everything - give every tenant their own box.
We learned to resist that instinct.
We share aggressively:
- One Worker, deployed once, serving every workspace.
- One routing layer.
- One D1 database (with room to shard later, not now).
We isolate deliberately:
- Consent records, per workspace.
- Domain and banner configuration, per workspace.
- Audit logs, per workspace.
The interesting decision was how to isolate the config in KV.
Our first version gave every workspace its own KV namespace. It felt clean. It was a mistake, and I’ll come back to why.
Today, config is isolated by key prefix inside a single shared namespace:
ws:{workspaceId}:config
ws:{workspaceId}:domains
One namespace. Logical isolation through naming. A workspace can never read another workspace’s keys because it never knows another workspace’s prefix, and the routing layer only ever hands it its own.
The principle we landed on: isolation should be logical until you have a concrete reason to make it physical. Physical isolation is expensive to operate, and most of the time it buys you nothing a prefix doesn’t.
Routing: Turning a Request Into a Tenant
Every request that hits the Worker has to answer one question before anything else happens:
Which workspace is this?
We have two kinds of traffic, and they resolve differently.
The first is the embed script - the snippet customers drop onto their site:
<script defer
src="https://consent.nuvocode.com/nuvo-consent.js"
data-nuvo-consent-key="nvc_2pio8x9bm65zvvhizoucrxnrj8rzq44k"></script>
That data-nuvo-consent-key carries a public site key, so the Worker knows the workspace from the request itself. No lookup, no ambiguity. The key is public by design; it grants nothing but the banner config that’s meant to be public anyway.
The second is everything that arrives on a hostname: the dashboard, the API, and customer custom domains.
For a while we resolved these by subdomain. Each workspace got its own subdomain under our domain. Classic SaaS.
Then customers wanted their own domains - consent.theircompany.com - and the subdomain model fell apart.
So we collapsed both cases into one. We use Cloudflare for SaaS custom hostnames, and the Worker resolves every hostname the same way:
const host = request.headers.get("host");
const workspaceId = await env.ROUTING.get(`host:${host}`);
if (!workspaceId) {
return new Response("Unknown host", { status: 404 });
}
// Attach tenant context to the request from here on.
const ctx = { workspaceId };
One KV read, at the edge, on the hot path. KV is built for exactly this - read constantly, written only when a domain is added.
Our subdomains and a customer’s custom domain are now the same thing to the system: a row in a hostname-to-workspace map. There’s no special case for “our” domains versus “their” domains. That uniformity removed a whole category of bugs.
The important detail: the workspace id comes from the hostname, resolved server-side. It never comes from the request body, a header the client controls, or a query parameter. The client cannot ask to be a different tenant.
The Data Layer: Row-Level Isolation in D1
For D1, the real question was row-level versus database-level isolation.
Database-per-tenant sounds safe. Each workspace, its own D1 database, physically impossible to cross.
It also means running every migration N times, provisioning a database on every signup, and giving up any query that spans tenants - including the internal analytics we need to operate the product. With thousands of small workspaces, that’s a lot of pain for isolation a WHERE clause already provides.
So we went row-level. One database, workspace_id on every tenant-scoped table, and workspace_id as the leading column of every index and primary key so the database is actually organized around it.
But row-level isolation is only as strong as the discipline filtering every query - and discipline is exactly what fails under deadline.
So we stopped relying on it.
Tenant queries don’t go through raw SQL. They go through a thin data-access layer that takes the workspace context from the request and injects the filter automatically. You physically cannot construct a tenant query without a workspace, because the function won’t compile without one:
// You can't call this without a workspace context.
function forWorkspace(ctx: TenantContext) {
return {
consents: () =>
db.prepare(
"SELECT * FROM consents WHERE workspace_id = ?"
).bind(ctx.workspaceId),
// ...
};
}
The filter isn’t a thing you remember. It’s a thing the type system requires.
That shift - from “always remember to filter” to “you can’t not filter” - is the single most important decision in this whole post.
Three Things We Got Wrong (and Changed)
The model above reads clean. Getting there was not.
1. We isolated KV physically before we needed to. A namespace per workspace felt principled. In practice we hit namespace management overhead, couldn’t provision them fast enough at signup, and bumped into binding limits. We tore it out and moved to prefix-based isolation in one shared namespace. Logical isolation was always enough.
2. We routed by subdomain and assumed it would scale. It did - until the first customer wanted their own domain, and the resolution path grew a special case. Rewriting it so every hostname resolves identically cost a weekend we’d have saved by treating custom domains as first-class from day one.
3. We trusted the application layer to filter by tenant. It mostly did. Then an internal admin query came within review of shipping without a workspace filter. Nothing leaked - but “nothing leaked because someone caught it in review” is not a control. That near-miss is why isolation now lives in the data-access layer, where a missing filter is impossible rather than merely discouraged.
The pattern across all three is the same: we reached for isolation that felt safe - separate namespaces, separate subdomains, careful queries - when what we needed was isolation the system enforces for us.
How We Think About Multi-Tenancy at Nuvo Code
If there’s one idea worth taking from this:
Good multi-tenancy isn’t about how much you separate. It’s about how little a single mistake can cost.
We share infrastructure aggressively and isolate data ruthlessly, but the isolation that matters is the kind a developer can’t accidentally bypass - resolved at the edge, enforced in the data layer, never trusted to memory.
Workers didn’t give us a multi-tenancy framework. They gave us constraints - edge execution, the KV-versus-D1 split, no central server to lean on - and those constraints pushed us toward a model that’s simpler and harder to break than the one we’d have built on a traditional stack.
We just had to be willing to throw away the first two versions to find it.
Multi-tenancy is hard enough when it’s your own product. You shouldn’t have to solve it again just to add a compliant cookie banner - that’s the entire point of Nuvo Consent. You drop in a script; we handle the tenant model, the isolation, and the compliance underneath it.
You don’t have to manage consent infrastructure yourself - that’s what Nuvo Consent is for.
RELATED NOTES