Sitemap
Jitin Bahri

Jitin Bahri

Founder, Studymitr | Technical Architect | AI & Distributed Systems | Product-First Engineering Leader

Multi-tenant SaaS: silo, pool or bridge?

11 min read1 day ago

--

How you share (or don’t share) infrastructure between customers decides your costs, your security story and your worst outages. A practical guide to isolation models, row-level security, noisy neighbours and per-tenant encryption.

Every B2B SaaS product makes a decision early, often without noticing it’s a decision: how much infrastructure do customers share?

Give every customer their own database and their own services, and you get strong isolation, easy per-customer customization, and an operations team managing hundreds of separate stacks. Put every customer in one shared database, and you get low cost and simple operations, plus the permanent risk that one missing WHERE tenant_id = ? shows one customer another customer's data.

In SaaS vocabulary, the customers are tenants, and the patterns have names. AWS’s SaaS guidance calls them silo, pool and bridge. This article walks through each one, how to choose, and the techniques that make the shared models safe: tenant context, Postgres row-level security, noisy-neighbour controls and per-tenant encryption. It ends with the part most teams eventually need: moving tenants between models.

Press enter or click to view image in full size
Everything separate, everything shared, or something in between

The three models

Silo: each tenant gets its own stack. Separate databases at minimum, often separate compute too, sometimes even a separate cloud account. Isolation is physical: a query can’t leak across tenants because the other tenant’s data isn’t there.

Pool: all tenants share everything. One set of services, one database, with every row tagged by tenant_id. Isolation is logical: the application (and ideally the database) filters by tenant on every access.

Bridge: a mix. Shared services with siloed data (a database per tenant behind shared compute), or a pooled default with siloed premium tenants. Most real SaaS products end up here.

  • Isolation: Silo: Strongest (physical); Pool: Logical, must be enforced; Bridge: Mixed by design
  • Cost per tenant: Silo: Highest; Pool: Lowest; Bridge: In between
  • Onboarding a tenant: Silo: Provision a stack; Pool: Insert a row; Bridge: Depends on tier
  • Noisy neighbours: Silo: None; Pool: The main operational risk; Bridge: Contained to the pooled part
  • Per-tenant customization: Silo: Easy; Pool: Hard; Bridge: Possible for siloed tenants
  • Operating 1,000 tenants: Silo: 1,000 stacks to upgrade; Pool: One stack; Bridge: One stack + the silos
  • Compliance asks (“our data, separate”): Silo: Easy to say yes; Pool: Needs strong arguments; Bridge: Sell silo as a tier
Press enter or click to view image in full size
There’s no free lunch: you trade cost and simplicity for isolation

An option in between: a schema per tenant

There’s a middle ground inside one database: give each tenant its own schema (a namespace of tables) in a shared Postgres instance. Queries are naturally scoped, because a session’s search_path points at one tenant's schema. Backups and restores per tenant become easier, and one tenant's data can be dropped cleanly.

Get Jitin Bahri’s stories in your inbox

Join Medium for free to get updates from this writer.

It works well for tens or a few hundred tenants. Beyond that, the costs show up. Every migration must run once per schema, so a schema change across 5,000 tenants is 5,000 migrations, and a failure halfway leaves tenants on different versions. The database catalog also grows with every table in every schema, which slows down tools and some planner operations. Many teams that start with schema-per-tenant later move small tenants into a shared pool and keep schemas (or databases) for the big ones.

How to choose

A few questions decide most cases:

  • How many tenants, and how big? Thousands of small tenants make silo uneconomical: most stacks would sit nearly idle. A handful of very large enterprise customers can justify it.
  • What do customers’ security teams require? Regulated industries often ask for data isolation, regional residency or their own encryption keys. Some will only sign if their data is physically separate.
  • How uneven is the load? If one tenant can generate 100 times another’s traffic, a pooled design needs strong per-tenant limits, or that tenant needs its own silo.
  • How fast must onboarding be? A free trial that takes 20 minutes to provision a silo loses signups. Pooling makes it instant.
  • Who will operate it? Running 500 database instances means 500 upgrades, 500 backups to verify and 500 sets of alerts. Automation helps, but it’s still real work.

The most common answer for a growing B2B product is a tiered bridge: pool for the free and standard tiers, silo (or a dedicated database) for enterprise customers who need and pay for it.

Making the pool safe: tenant context everywhere

In a pooled system, the single most important property is that every request knows its tenant, and every data access is scoped to it. Establish the tenant once, at the edge, from something trustworthy (the authenticated token, not a header the client can set), and carry it through the request:

type ctxKey struct{}

func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFrom(r.Context()) // set by the auth middleware, from a verified token
if !ok || claims.TenantID == "" {
http.Error(w, "no tenant", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ctxKey{}, claims.TenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func TenantFrom(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}

The same tenant ID should flow into logs, metrics, traces, queue messages and background jobs. A job that processes “all invoices due today” must still run per tenant, with the tenant context set, or it becomes the place where data mixes.

Press enter or click to view image in full size
If any hop loses the tenant, that hop is where data will eventually leak

Defence in depth: row-level security in Postgres

Relying on every query to include WHERE tenant_id = $1 works until the day someone forgets. Row-level security (RLS) makes the database enforce it, so a forgotten filter returns nothing instead of everyone's data:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- apply the policy to the table owner too

CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

USING filters what rows can be read, updated or deleted. WITH CHECK prevents inserting or moving a row into another tenant. The application sets the tenant for each transaction:

func (s *Store) InTenant(ctx context.Context, fn func(pgx.Tx) error) error {
tenant := TenantFrom(ctx)
if tenant == "" {
return errors.New("no tenant in context") // fail closed
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
// is_local = true: the setting ends with this transaction,
// so a pooled connection can never carry it into the next request.
if _, err := tx.Exec(ctx, `SELECT set_config('app.tenant_id', $1, true)`, tenant); err != nil {
return err
}
return fn(tx)
})
}
Press enter or click to view image in full size
A forgotten WHERE clause returns zero rows, not every customer’s data

Three details that catch teams out:

  • Superusers and roles with BYPASSRLS ignore policies, and so does a table's owner unless you FORCE it. Connect the application as a dedicated, non-owner role.
  • Use transaction-local settings. A session-level SET on a pooled connection can leak one request's tenant into the next request that reuses the connection. set_config(..., true) scopes it to the transaction.
  • Index for the policy. Put tenant_id first in your indexes ((tenant_id, created_at)), because every query now filters by it.

RLS is a safety net, not a replacement for scoping queries in the application. Use both. And check the performance: a policy is evaluated on every row access, so keep its expression simple (a direct comparison against a setting, as above) rather than a subquery.

Noisy neighbours

In any shared system, one tenant’s spike becomes everyone’s problem: a bulk import, a runaway integration, or a report that scans a year of data. The fixes are about limiting each tenant’s share:

  • Per-tenant rate limits at the API, scaled by plan, so no single tenant can consume the whole fleet.
  • Per-tenant concurrency limits for expensive work: at most N report jobs or N bulk imports running at once for each tenant.
  • Fair queueing for background jobs. One queue per tenant (or weighted round-robin across tenants), so a tenant with 100,000 queued jobs doesn’t make everyone else wait.
  • Quotas on storage and data volume, enforced before they become an incident.
  • Per-tenant metrics. You can’t find the noisy neighbour if every dashboard shows only totals. Tag requests, queries and jobs with the tenant ID.
Press enter or click to view image in full size
Pooling is only as safe as the limits around each tenant

When one tenant is consistently much larger than the rest, that’s a signal to move it to its own silo, which is exactly what the bridge model allows.

Per-tenant encryption

Enterprise customers increasingly ask, “Is our data encrypted with our own key, and can we revoke it?” In a pooled database, you can still give each tenant its own key with envelope encryption:

  1. Each tenant has a key-encryption key (KEK) in a key management service (AWS KMS, Google Cloud KMS, Azure Key Vault, or HashiCorp Vault). It never leaves the KMS.
  2. Sensitive fields are encrypted with a data key, which is itself stored encrypted by the tenant’s KEK.
  3. To read, the service asks the KMS to decrypt the data key (cached briefly), then decrypts the field locally.
Press enter or click to view image in full size
Revoke one tenant’s master key and only that tenant’s data becomes unreadable

If a tenant’s KEK is disabled (“crypto-shredding”), that tenant’s encrypted data becomes unreadable, even though it sits in the same tables as everyone else’s. Some providers support customer-managed keys, where the KEK lives in the customer’s own cloud account. It adds latency and failure modes (the KMS is now on the read path), so encrypt the sensitive fields that need it, not every column.

Test isolation like a security feature

Tenant isolation is a security property, so test it like one, automatically and on every change:

  • Cross-tenant tests: create two tenants with overlapping data, authenticate as tenant A, and try to read, update and delete tenant B’s records through every API endpoint. Every attempt should fail with “not found”, not “forbidden” (which would confirm the record exists).
  • RLS tests at the database level: open a transaction as tenant A, run raw queries without any WHERE clause, and assert that only A's rows come back.
  • Missing-context tests: run a request or job with no tenant set, and assert it fails closed rather than seeing everything.
  • Background job tests: make sure jobs, exports and webhooks run with the right tenant context, since those paths often skip the request middleware.
func TestTenantCannotReadOtherTenantsInvoices(t *testing.T) {
a, b := newTenant(t, "acme"), newTenant(t, "bigco")
inv := b.CreateInvoice(t, 4999)

res := a.Get(t, "/invoices/"+inv.ID) // acme's token, bigco's invoice
if res.StatusCode != http.StatusNotFound {
t.Fatalf("acme got %d for bigco's invoice, want 404", res.StatusCode)
}
}

Per-tenant configuration

Tenants differ in more than data: plan limits, enabled features, SSO settings, data retention, webhooks. Keep this in one tenant configuration store (cached, with change events), and read limits and flags from it instead of scattering if tenant == "bigco" through the code. It's also where the tenant directory naturally lives, and it makes enterprise customizations a configuration change rather than a code fork.

Data residency

Some customers require their data to stay in a specific country or region. The tenant directory handles this too: each tenant has a home region, and their data lives only in stacks or databases in that region. Requests from anywhere are routed to the tenant’s home region. Keep the global pieces (login, billing, the directory itself) minimal and free of customer data, so residency promises stay true.

Moving tenants between models

Sooner or later, a pooled tenant needs to move to a silo (they grew, or they signed an enterprise deal with isolation requirements), or a siloed tenant moves back into the pool. Designing for this from day one makes it routine:

  • Keep a tenant directory: a small service or table that says where each tenant’s data lives (which database, which region, which cell). Every request looks it up (and caches it).
  • Keep schemas identical across pool and silo, so a tenant’s rows can move without transformation.
  • Move with copy, catch-up and flip: copy the tenant’s rows to the new location, catch up with change capture, briefly pause writes for that tenant, flip the directory entry, then delete the old copy.
Press enter or click to view image in full size
Copy, catch up, flip the directory, clean up: one tenant at a time

Large SaaS platforms often push this further into a cell-based architecture: many identical, independent copies of the whole stack (“cells”), each serving a subset of tenants. A bad deploy or a noisy tenant affects only one cell, and growth means adding cells.

Operating many silos

If you do run siloed tenants, the operational burden is what decides whether it stays manageable:

  • Everything is automated. Provisioning a tenant’s stack, applying migrations, rotating credentials and upgrading versions must be one command (or one pipeline run) for all silos, never a manual step per customer.
  • One version, everywhere. Allowing silos to drift onto different application versions turns every bug report into an investigation of which version that customer runs. Deploy to all silos in waves, with the same code.
  • Fleet-wide monitoring. Dashboards and alerts must cover every silo by default, with the tenant as a label, so a problem in one customer’s stack is visible without someone checking it specifically.

Cost per tenant

In a pooled system, the cloud bill arrives as one number, but pricing decisions need cost per tenant. Tag what you can (siloed resources), and estimate the rest from usage: requests, database time, storage and data transfer per tenant, from the per-tenant metrics you already need for noisy neighbours. Knowing that one plan tier costs more to serve than it earns is one of the most valuable numbers a SaaS business can have.

TL;DR

  • Silo (separate stacks) maximizes isolation and cost. Pool (shared everything) minimizes cost and operations. Bridge mixes them, and is where most products end up.
  • Choose by tenant count and size, compliance demands, load skew, onboarding speed and operational capacity. A common answer: pool by default, silo for enterprise.
  • In a pool, establish the tenant at the edge from a verified token, and carry it through every call, job, log and message.
  • Add Postgres row-level security as a database-enforced safety net: FORCE it, use a non-owner role, and set the tenant per transaction.
  • Contain noisy neighbours with per-tenant rate limits, concurrency limits, fair queues and quotas.
  • Offer per-tenant keys with envelope encryption, and keep a tenant directory so tenants can move between models.
Press enter or click to view image in full size
Save this one for your next SaaS architecture review

Which model is your product on, and which way would you move if you could? Tell me in the comments.

--

--