Skip to main content
Multi-Tenant SaaS Architecture for an MVP
Engineering

Multi-Tenant SaaS Architecture for an MVP

Every B2B SaaS becomes multi-tenant the day its second customer signs up. Founders then get tenancy wrong in one of two directions: ignoring it until a customer sees someone else's invoices, or provisioning a database per tenant for their first three customers. Both are expensive, and both are avoidable.

How should you architect a multi-tenant SaaS MVP?

Put every tenant in one shared Postgres database with a tenant_id column on every tenant-owned table, and enforce isolation with Postgres Row-Level Security so a forgotten WHERE clause can't leak one customer's data to another. Save schema-per-tenant and database-per-tenant for the specific customers — usually enterprise or regulated — whose contracts demand stronger separation.

The real question is where isolation is enforced: by a query filter, a namespace or a connection string. Each step buys stronger guarantees at a higher operating cost; the classic mistake is paying for the strongest before any customer asks.

How do the three multi-tenancy models compare?

Shared schema + tenant_idSchema per tenantDatabase per tenant
IsolationLogical, enforced by RLSLogical, via namespacesPhysical, separate databases
CostLowest — a new tenant is a rowLow infra, rising opsHighest — a database each
Migration complexityLow — runs onceHigh — runs per tenantHigh — runs per database
Noisy-neighbour riskHighest — everything sharedHigh — same serverLowest — separable resources
Enterprise-readinessPasses most questionnairesMuch like sharedMeets strict isolation and residency terms
Best forAlmost every MVPDozens of tenants, or retrofitting tenancyRegulated or enterprise tenants who pay for it

Shared database, shared schema. Every tenant's rows share the same tables, tagged with a tenant_id. It's the cheapest model — one schema, one migration, one backup — and scales further than founders expect: with tenant_id leading your composite indexes, one Postgres instance serves thousands of typical B2B tenants, and partitioning takes it further. The risk is equally simple: one missed WHERE clause leaks data.

Shared database, schema per tenant. Each tenant gets its own Postgres schema — an identical set of tables — selected per request via search_path. Exporting or deleting a customer is one pg_dump or DROP SCHEMA. But every migration runs once per tenant: 500 tenants means 500 migrations, any of which can fail halfway and leave customers on different versions — and most ORMs assume one fixed schema.

Database per tenant. Each tenant gets its own database — sometimes its own server — with separate credentials, backups and, if required, region and encryption keys. It's the strongest isolation short of a separate deployment per customer, and the most expensive to run: provisioning must be automated, migrations fan out across every database, and "how many active users do we have?" becomes an ETL job.

What is Row-Level Security and why does it matter?

Row-Level Security (RLS) is a Postgres feature that attaches access policies to a table, so the database itself decides which rows each query can see or change. Enable it, add a policy, and every read, update and delete is scoped to the current tenant, while writes carrying another tenant's ID are rejected:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

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);

Each request sets its tenant inside its transaction — SELECT set_config('app.tenant_id', $1, true) — from the organisation ID in the authenticated session. A query that forgets its filter then sees only that tenant's rows, and one that never sets a tenant errors rather than returning everyone's data.

Two details decide whether it protects you. Connect as a role RLS applies to: superusers and BYPASSRLS roles skip policies, as do table owners unless you add FORCE ROW LEVEL SECURITY — so run migrations as the owner and the app as a restricted role. And keep the setting transaction-local (the final true), or a pooled connection can carry one request's tenant into the next.

That turns isolation from a discipline problem — every engineer remembering every filter, forever — into a guarantee the database enforces: a backstop that makes the inevitable slip harmless, and a concrete control to cite in a security questionnaire.

When do you need database-per-tenant?

When a specific customer's contract demands it — which, at MVP stage, is rarer than founders fear. The trigger is usually an enterprise security questionnaire asking whether customer data is "logically or physically segregated". For most mid-market buyers, "logically, enforced by database Row-Level Security", with evidence it's tested, is accepted. Physical isolation gets forced when:

  • The contract mandates it. Some banks, insurers, healthcare and public-sector buyers require dedicated infrastructure, full stop.
  • Data residency applies. Data must stay in the UK or EU — though one shared database per region often solves that more cheaply. Our UK GDPR guide for SaaS startups sets out what the law itself requires.
  • They want their own encryption keys. Customer-managed keys are far simpler with a dedicated database.
  • They need per-tenant restore. "Roll back just our data to Tuesday" is routine in a dedicated database and delicate surgery in a shared one.
  • One tenant dwarfs the rest, and still degrades everyone after per-tenant rate limits and statement timeouts.

The practical shape is a hybrid: everyone else stays pooled, and that customer gets a dedicated database behind the same codebase, priced as an enterprise line item. The same questionnaire will usually ask for SSO too — covered in Clerk vs Auth0 vs NextAuth.

Can you migrate between models later?

Yes — lifting individual tenants out of a shared database is routine if you plan for it; retrofitting tenancy onto a product that never had it is the migration to fear. With a tenant_id on every row, extracting a customer is mechanical: provision a database, copy their rows, repoint their routing, delete the originals. Four day-one habits keep that option open:

  • tenant_id on every tenant-owned table, join tables included — not merely reachable through foreign keys.
  • UUID primary keys, so rows can move between databases without colliding.
  • Tenant resolution in one place — middleware that turns the session into a tenant-scoped connection.
  • A tenant-to-database lookup, even while every tenant maps to the same database — the seam a dedicated database plugs into later.

It's the sequencing argument from monolith vs microservices: build the seam now; pay for the split when a real customer requires it.

Our default: shared schema, tenant_id and Postgres RLS

For the SaaS products we build, the default is one shared Postgres database, a tenant_id on every tenant-owned table, RLS on all of them, and a tenant-aware data layer on top. It adds no infrastructure to the SaaS MVP tech stack we ship with — RLS is Postgres-native, one more reason Postgres is our default database (the wider case is in Postgres vs MongoDB).

The reasoning is the cost of being wrong. It's the only model that's cheap on day one and cheap to leave: the tenant_id powering your policies is the column that lets you lift a tenant out later. That's the escape hatch — when an enterprise deal genuinely needs isolation, we provision a dedicated database, copy that customer's rows across and repoint the lookup. Days of work, not a re-architecture, and priced into their contract.

What we accept is shared fate: a noisy tenant can slow everyone until rate limits catch it, a bad migration hits every customer at once, and our answer to "is data physically segregated?" is "no — logically, and here's the control". For most B2B buyers, that's fine.

Frequently asked questions

Is a shared database secure enough for B2B SaaS?

Yes, for most B2B buyers — provided isolation is enforced by the database through Row-Level Security, not by every query remembering its filter. Salesforce, about as enterprise as SaaS gets, has long kept customers' data in shared tables keyed by an organisation ID. Reviewers want a clear control, evidence it works — automated cross-tenant read tests are cheap to write — and audit logging. Physical separation is a minority requirement, mostly from regulated buyers.

Is single-tenant ever the right choice for an MVP?

Rarely — a separate deployment of the whole application per customer only makes sense when a handful of very large customers each pay enough to fund their own infrastructure. Every customer multiplies your deploys, monitoring and support, and every bug fix ships once per customer. On-premise enterprise, defence and some regulated-finance products genuinely need it; an MVP still searching for product-market fit is trading scarce engineering time for isolation nobody is paying for.

Does Row-Level Security slow Postgres down?

Not meaningfully, when policies are simple equality checks on an indexed tenant ID column — the policy becomes one extra predicate the index serves cheaply. Slow policies run a subquery per row, such as a memberships lookup. Keep tenant policies to a direct column comparison, lead composite indexes with the tenant ID, and handle finer-grained permissions in the application, where they're easier to test.

Want a tenancy model sized to your real customers?

The right model depends on who you sell to and what their procurement teams will ask. If you want an architecture scoped around your actual pipeline — including an honest read on whether anyone will need dedicated isolation — book a free scoping call. We'll map it out and quote the build fixed-price, so you know the cost before committing.

Sameer AhmadCo-Founder & CTO, Coderacle

Sameer is the co-founder and CTO of Coderacle, a London software studio building SaaS MVPs for UK founders. He leads engineering and architecture on every build — stack decisions, scalable foundations, and getting products to production without the usual rewrites.

Leave a comment