Multi-tenancy looks simple until your first enterprise customer asks about isolation.
Where do you isolate? The database? The schema? The application? The Kubernetes namespace? The network? And what happens when one tenant grows 100x larger than everyone else and starts eating everyone else's lunch?
This post covers the full high-level design: isolation models with real tradeoffs, the noisy neighbor problem, tiering, and per-tenant observability and cost attribution.
System Architecture: The Tenant Request Path
The layer diagram below shows where things live. This one shows what happens on a single request, because most multi-tenancy bugs are request-path bugs: a tenant resolved wrong, a context dropped, a query routed to the wrong store.
+--------+ +-------------------+ +-------------------------+
| Client |---->| Edge / Ingress |---->| Tenant Resolver |
+--------+ | (WAF, TLS, LB) | | (subdomain or token |
+-------------------+ | mapped to tenant_id) |
+------------+------------+
|
v
+-----------------------------------+
| Application Tier |
| (tenant context attached to every |
| request, propagated to workers) |
+---+-----------+-----------+-------+
| | |
v v v
+------------+ +--------+ +----------------+
| Data Tier | | Async | | Observability |
| (database, | | Workers| | (tenant_id on |
| schema, | | (queue | | every log, |
| or RLS | | per | | metric, trace)|
| routing) | | tier) | +----------------+
+------------+ +--------+
Two properties of this path matter more than any specific technology choice.
First, tenant resolution happens exactly once, at the edge, and everything downstream trusts the context, not the client. If any service re-derives the tenant from a request parameter, you have a confused-deputy bug waiting to happen.
Second, separate the control plane from the data plane. The tenant registry (tenant_id to database, tier, quota) is control plane: cached aggressively, updated rarely, and safe to read on a hot path. Tenant migrations, tier promotions, and quota changes are control plane writes. The data plane never blocks on control plane writes; it reads a cached mapping with a short TTL and keeps serving.
HLD: The Layers of a Multi-Tenant Platform
The design question is always the same: at which layers do you isolate, and at which do you share?
+-------------------------+
| Edge / Ingress |
| Tenant router + WAF |
+------------+------------+
| tenant_id resolved
+------------v------------+
| Application Tier |
| Tenant-aware services |
| (shared or per-tier) |
+------------+------------+
|
+----------------------+----------------------+
| | |
+----------v----------+ +--------v--------+ +-----------v-----------+
| Data Tier | | Async Workers | | Observability |
| (isolation model | | (per-tenant | | (per-tenant labels, |
| varies, see below)| | queues/pools) | | dashboards, alerts) |
+---------------------+ +-----------------+ +-----------------------+
Sharing everything is cheapest. Isolating everything is safest. Production systems land somewhere in between, and the right answer depends on tenant count, compliance requirements, and how different your tenants' workloads are.
Isolation Model 1: Database per Tenant
Each tenant gets its own database. This is the strongest isolation you can buy.
How it works: a tenant registry maps tenant_id to a connection string. Application code resolves the tenant from the request, looks up its database, and connects. Nothing is shared at the data layer.
Strengths:
- Blast radius is contained. One tenant's runaway query cannot affect another tenant's database.
- Backups, restores, and compliance deletion are per tenant. Enterprise customers expect this.
- You can place large tenants on dedicated hardware without redesigning anything.
Costs:
- Operational overhead scales with tenant count. Schema migrations must run against every database. With 500 tenants, a migration is 500 migrations. You need tooling for this: a migration runner that iterates tenants, with per-tenant failure handling and resume.
- Connection pools multiply fast: 20 connections times 200 tenants is 4,000 per app instance. PgBouncer in transaction pooling mode becomes mandatory.
When it fits: tens to low hundreds of tenants, enterprise customers with compliance requirements, or workloads where tenants are large enough to justify the overhead. If your average contract is six figures, the operational cost is noise.
Isolation Model 2: Schema per Tenant (PostgreSQL)
One database cluster, one schema per tenant. Tables are duplicated per schema; the application sets search_path per request.
This is the middle ground. You get logical separation without the connection and operational overhead of separate databases.
Strengths:
- Single connection pool, single backup pipeline, single cluster to monitor.
- Tenant data is still physically separated at the schema level. A missing tenant filter cannot leak data across tenants the way it can in shared tables, because the tables themselves are separate.
- Per-tenant restore is possible, though fiddlier than a database restore.
Costs:
- Migrations still fan out across schemas. Better than N databases, but you still need the migration runner.
- Schema count has practical limits. PostgreSQL handles thousands of schemas, but tens of thousands starts to hurt (catalog bloat, slow DDL).
- Cross-tenant analytics across schemas are painful; you will need a separate analytics pipeline anyway.
When it fits: hundreds to low thousands of tenants on PostgreSQL, where you want real separation but cannot justify a database per tenant.
Isolation Model 3: Shared Tables with tenant_id
One database, one set of tables, every row carries a tenant_id. This is the cheapest model to operate and the easiest to get catastrophically wrong.
The entire model rests on one invariant: every query filters by tenant_id. Miss it once in one query, and you have a data leak. This is not a theoretical risk. It is the most common multi-tenancy bug in production.
How to make it safe:
- PostgreSQL Row Level Security (RLS): the database enforces tenant filtering regardless of the query. Set the tenant in a session variable per request and let RLS handle it.
- Or enforce it in a data access layer every query goes through. No raw SQL outside the layer.
- Composite indexes with tenant_id first:
(tenant_id, created_at). Without this, per-tenant queries scan the whole table.
Strengths:
- One schema to migrate, one pool, trivial analytics across tenants.
- Scales to millions of tenants.
Costs:
- Noisy neighbor risk is highest here because everything shares the same tables and indexes. A tenant running a heavy analytical query affects everyone.
- Per-tenant backup and restore is effectively impossible. You restore the whole database or nothing.
- Compliance deletion means careful DELETEs, not DROP DATABASE.
When it fits: high tenant counts (thousands to millions), relatively uniform workloads, cost-sensitive. Most B2B SaaS with SMB customers lands here.
The Tradeoff, Summarized
| DB per tenant | Schema per tenant | Shared tables | |
|---|---|---|---|
| Isolation strength | Strongest | Strong | Weakest (needs RLS) |
| Operational cost | Highest | Medium | Lowest |
| Tenant ceiling | Hundreds | Thousands | Millions |
| Per-tenant backup | Trivial | Possible | Impractical |
| Migration complexity | N databases | N schemas | One schema |
| Noisy neighbor risk | Lowest | Low | Highest |
Most platforms end up hybrid: shared tables for the standard tier, dedicated databases for enterprise tenants. Which brings us to tiering.
Application-Level Isolation
Regardless of the data model, the application tier needs tenant awareness in every request.
The pattern: resolve tenant once at the edge, propagate everywhere.
- Ingress resolves tenant_id from the auth token or subdomain, never a client-supplied header alone. The tenant derived from identity is the decision, not the client's claim.
- Middleware attaches tenant context to the request (Go context, Python contextvars, or equivalent).
- The data layer uses the context to route: pick the right database, set the schema search_path, or set the RLS session variable.
The classic failure: background jobs that lose tenant context. Every job payload must carry tenant_id, and workers must re-establish context before doing anything.
Kubernetes: Namespace Strategies
On Kubernetes, the namespace is the natural isolation boundary.
Option A: namespace per tenant. Strongest isolation, with per-tenant ResourceQuotas, NetworkPolicies, and RBAC. Works well up to hundreds of namespaces; beyond that, the API server and GitOps tooling feel the strain, and each namespace needs templated supporting resources.
Option B: namespace per tier (tenant-standard, tenant-enterprise). Fewer namespaces, coarser isolation. Most teams land here.
Option C: single namespace, tenant as a label. Simplest, but noisy neighbor protection is application-level only.
NetworkPolicies matter in both models: default-deny with explicit allows in shared namespaces, and no cross-namespace traffic between tenant namespaces in the per-tenant model.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-isolation
namespace: tenant-acme
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: tenant-acme # only same-tenant traffic
- namespaceSelector:
matchLabels:
name: platform-shared # plus shared platform services
The Noisy Neighbor Problem
One tenant's workload degrading everyone else's is what kills multi-tenant platforms in production. The defenses, in order of importance:
1. ResourceQuotas per namespace. Hard ceilings on CPU, memory, and object counts per tenant or tier. This is the single most effective control. Without it, one tenant's memory leak evicts everyone else's pods.
apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-quota
namespace: tenant-acme
spec:
hard:
requests.cpu: "20"
requests.memory: 64Gi
limits.cpu: "40"
limits.memory: 128Gi
pods: "50"
2. Rate limiting at ingress. Per-tenant token buckets on API requests. A tenant doing 10x their normal traffic gets 429 responses, not everyone else's latency. Implement at the ingress controller or in a gateway layer.
3. Bulkhead pattern for workers. Partition workers by tier: a standard queue and an enterprise queue. A tenant flooding the queue only floods their tier. In Go, separate goroutine pools with separate channels; in Python, separate Celery queues.
4. Database-level protection. Statement timeouts per role, separate pool allocations per tier, query cost guards for shared tables.
The 100x Tenant
Every successful multi-tenant platform eventually gets a tenant 100x larger than the median, breaking every assumption the shared infrastructure was built on. Do not try to make shared infrastructure handle them. Tier them out.
The pattern: detect via per-tenant resource metrics, define thresholds for tier promotion, then provision a dedicated namespace with its own node pool (or a dedicated database). The application routes by tenant_id; the tenant never knows.
# Dedicated node pool for the largest tenant
apiVersion: v1
kind: Pod
metadata:
namespace: tenant-whale
spec:
tolerations:
- key: "dedicated"
operator: "Equal"
value: "tenant-whale"
effect: "NoSchedule"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: workload
operator: In
values: ["tenant-whale"]
Dedicated infrastructure is also where per-tenant SLAs become possible. You cannot promise 99.99% to a tenant sharing infrastructure with 999 others.
Scalability: What Breaks at 10x and 100x
Tenants 10x: the control plane feels it first
The data plane usually survives 10x tenants before the control plane does. The tenant registry, the migration runner, and the onboarding flow were built for dozens of tenants and now serve hundreds.
- The migration runner that iterated 50 databases now iterates 500. Per-tenant migration time is unchanged; total migration time is 10x, and your deploy window is not. Parallelize migrations with a worker pool, run them in dependency order, and keep per-tenant failure handling with resume. One failed tenant must not block the other 499.
- The tenant registry cache needs a TTL strategy. Tenants move tiers; a stale mapping routes an enterprise tenant to shared infrastructure (or worse, a standard tenant to a database they should not reach). Short TTLs plus an explicit cache-bust on tier changes.
- Connection pools multiply. Twenty connections per app instance times 500 tenants in a database-per-tenant model is 10,000 connections per instance. PgBouncer in transaction pooling mode stops being optional and starts being the architecture. In shared-table models this problem barely exists, which is one more reason the model choice compounds over time.
Tenants 100x: sharding and the write ceiling
At some point the shared primary hits its write ceiling. The standard answer is sharding by tenant_id: hash the tenant to a shard, keep all of a tenant's data on one shard so per-tenant queries stay single-shard.
The catch is that sharding is a one-way door. Re-sharding is painful, cross-shard queries are slow, and joins across tenants become an analytics-pipeline problem. Do not shard on day one. But do keep the option open: if every query already carries tenant_id and every join is tenant-scoped, sharding later is a routing change, not a rewrite. The discipline that makes shared tables safe is the same discipline that makes sharding possible.
Read scaling is the easier half: read replicas per tier, with a documented replica lag SLO. Writes that need read-after-write consistency stay on the primary; everything else can tolerate a lagging replica. The mistake is treating all reads as equal and either sending everything to the primary (wasteful) or everything to replicas (stale reads in the write path).
Noisy neighbors at scale
Quotas and rate limits handle the median case. At scale, the tail cases need structural answers:
- Separate the analytics read path. The tenant running a full-table export should hit a dedicated analytics replica, never the primary serving live traffic. Route by query type, not just by tenant.
- Statement timeouts and query cost guards per role or per tier. A runaway query gets killed by the database, not by a human at 3am.
- When a tenant outgrows the shared tier, do not keep tuning the shared tier. Promote them to dedicated infrastructure, as described in the 100x tenant section. Tiering is the scalable form of noisy neighbor protection.
Moving tenants between models
The hybrid model (shared for standard, dedicated for enterprise) implies tenant migration: a tenant starts on shared tables and graduates to a dedicated database. This is one of the highest-risk operations in multi-tenant platforms, so it needs a runbook, not heroics.
The safe pattern is dual-write with verification: write to both the old and new store, backfill historical data, run a verification pass comparing row counts and checksums per tenant, then flip the router mapping and drain the old writes. Rollback is flipping the mapping back. Every step is per tenant and idempotent, because the migration will fail halfway at least once and you need to resume, not restart.
Tenant-Aware Routing
Something has to map an incoming request to the right backend. Options:
- Subdomain routing:
acme.app.comresolves the tenant at ingress. Clean, but wildcard certs and DNS add operational surface. - Header or path routing: simpler operationally, slightly less elegant.
- A tenant router service: looks up tenant_id to backend pool and proxies accordingly. Most flexible; adds a hop.
The mapping must be dynamic. Tenants move between tiers, so the router config must update without restarts.
Per-Tenant Observability
You cannot operate what you cannot see per tenant.
- Metrics: label by tier, not tenant_id. Per-tenant labels explode cardinality with thousands of tenants; keep per-tenant detail in logs and traces.
- Logs and traces: tenant_id in every structured log line and as a span attribute. When a customer reports slowness, filter by their tenant and see where time went.
- Dashboards: one per tier for operations, plus the ability to drill into a single tenant on demand.
- Alerting: per-tier thresholds. Alert on enterprise tier p99 latency breaching SLO, not just global averages that hide one tenant's pain.
Per-Tenant Cost Attribution
If you cannot attribute cost per tenant, you cannot price correctly. This is the cost engineering side of multi-tenancy.
The approach:
- Tag everything. Cloud resources get tenant or tier tags. Kubernetes namespaces get labels.
- For shared compute (a node running pods from 20 tenants), allocate by usage: namespace CPU and memory requests as a fraction of node capacity, using OpenCost or a custom allocator. It is approximate, but approximate and directionally correct beats unknown.
- Split costs into per-tenant buckets: compute, storage, transfer, managed services, AI and API spend. If your largest tenant consumes 40% of infrastructure but pays 10% of revenue, the data should force a pricing conversation. The machinery (tag, measure, allocate, report) is the same whether the unit is a tenant, a team, or a feature.
Security Considerations
Multi-tenancy turns ordinary security bugs into cross-customer incidents. The threat model is simple: one tenant must never see, affect, or infer another tenant's data or behavior.
Identity and tenant binding
The tenant is derived from the authenticated identity (the token, the subdomain bound to the account), never from a client-supplied header or parameter alone. Accepting X-Tenant-ID at face value is a tenant-confusion vulnerability: any authenticated user can read any tenant's data by changing a header. If you must accept a tenant hint from the client, verify it against the identity before doing anything with it.
Background jobs deserve the same scrutiny. A job payload carries tenant_id, and the worker re-establishes and verifies the tenant context before touching data. A worker that processes jobs without tenant binding will eventually process one tenant's job against another tenant's data.
Data access controls
Defense in depth at the data layer, because application code will have bugs:
- With RLS, remember that policies do not apply to the table owner. Application connections should use a role that is not the table owner, or the policy is decoration.
- With schema-per-tenant, pin
search_pathper request (preferSET LOCALinside a transaction) and never let user input influence schema names. - Separate database roles per tier, with statement timeouts and connection limits per role. A compromised or buggy standard-tier service account should not be able to exhaust the enterprise tier's pool.
Encryption and keys
TLS everywhere in transit, including between internal services. At rest, encrypt volumes and backups. For enterprise tiers, per-tenant KMS keys mean you can cryptographically delete a tenant's data by destroying their key, which turns compliance deletion from a careful DELETE into a key rotation event. Backups inherit the same encryption; an unencrypted backup is a copy of the database with weaker access controls.
PII in telemetry
tenant_id on a log line is operational metadata, and you need it. But logs and traces also collect request bodies, query parameters, and error payloads, which collect PII. Redact at the collection layer, enforce per-tenant log retention, and know your deletion story before a customer asks: with database-per-tenant it is a DROP; with shared tables it is a careful, verified DELETE across every table; with backups in the picture it is a retention policy, because you cannot surgically delete from last month's snapshot.
Attack surface worth naming
- Cross-tenant request forgery: mitigated by deriving tenant from identity and enforcing it in the data layer (RLS, separate schemas), not just the application.
- Subdomain takeover: if tenants get subdomains, lock down DNS and certificate issuance so a lapsed tenant subdomain cannot be claimed by an attacker.
- SSRF through tenant-configured webhooks: a tenant-supplied callback URL is an attacker-controlled URL. Egress filtering and a webhook proxy with an allowlist.
- Tenant existence oracles: error messages and timing differences that reveal whether a tenant_id exists. Normalize responses on the auth path.
- Insider access: the control plane (tenant registry, migration runner) is the key to every kingdom. Audit-log every control plane write, require MFA, and restrict who can run the migration tooling.
Production Checklist
Monitoring
- Per-tier SLOs (p99 latency, error rate, availability), not just global dashboards that hide one tenant's pain.
- Per-tenant drill-down on demand: tenant_id on every log line, span, and metric label where cardinality allows.
- A synthetic canary tenant in each region that exercises the full request path on a schedule. It catches router misconfigurations and tier-mapping errors before real tenants do.
- Pool and quota utilization: alert on pool exhaustion trends, not just exhaustion.
Alerting
- Per-tenant p99 latency vs. the tier median. A tenant drifting from its tier is either a noisy neighbor or a customer about to churn; both deserve a page or a ticket.
- Migration runner failures: per-tenant failure alerts with the tenant_id and the failing step.
- Quota exhaustion warnings at 80 percent, not 100.
Runbooks
Write these before you need them: tenant onboarding (provisioning steps per isolation model), tier promotion (the dual-write migration above), noisy tenant isolation (move to dedicated pool or throttle), and per-tenant restore (which differs completely by isolation model).
Failure modes to rehearse
- Router cache staleness after a tier move: the tenant hits the wrong backend until the cache expires. Mitigation: explicit cache-bust on tier changes, short TTLs.
- Tenant context loss in async jobs: jobs processed without tenant binding. Mitigation: schema validation on job payloads requiring tenant_id.
- Pool exhaustion cascade: one tier's pool fills, connections queue, latency spikes everywhere. Mitigation: per-tier pools, PgBouncer, circuit breakers.
- Partial migration: half the tenants on the new schema. Mitigation: idempotent, resumable migrations with per-tenant status tracking.
- Thundering herd on deploy: every instance re-resolves every tenant at once. Mitigation: staggered rollouts, jittered cache TTLs.
Graceful degradation
- Per-tenant feature flags: disable an expensive feature for the tenants abusing it without a deploy.
- Load shedding by tier: when the platform is saturated, shed standard-tier traffic before enterprise-tier traffic. This is a business decision encoded in the load shedder.
- Read-only mode per tenant or per tier when the primary is unhealthy.
- Circuit breakers on downstream dependencies, keyed per tenant, so one tenant's downstream failure does not consume everyone's threads.
Where to Start
If you are building this from scratch:
- Start with shared tables plus RLS from day one. You can migrate the data model later; you cannot retrofit the discipline.
- Resolve tenant at the edge, propagate via context, never lose it in async jobs.
- Set ResourceQuotas before you need them.
- Build per-tenant metrics and logging from the start. Retrofitting is miserable.
- Define tiering thresholds early. When the whale arrives, you want a playbook, not a redesign.
Multi-tenancy is a dozen small decisions about where to share and where to isolate, revisited as you grow. The platforms that survive made isolation cheap to add later: tenant context everywhere, quotas from day one, and a data model that does not fight tiering when the time comes.