Separate install per customer, or one shared app? Multi-tenant SaaS architecture and tenant isolation
Every new customer restarts the same argument on the team: do we spin up another install, or put them on the existing app? The short answer comes from two numbers, how many customers you expect and how much isolation they demand. With a handful of customers who each insist their data sits somewhere separate, a dedicated stack per customer is defensible. If you plan to serve hundreds, shared infrastructure is the only sustainable path, because as Microsoft's multitenancy guidance puts it, if one tenant requires a certain infrastructure cost, 100 tenants probably require 100 times that cost. Most mature products land somewhere in between: shared by default, dedicated for the customers who ask and pay for it. The hard part isn't picking a model. The same guidance warns that switching to a different model later is sometimes costly, which is why this belongs on the table before your first customer, not after your fiftieth.
Three models: silo, pool, and the mix
The naming that stuck comes from AWS's SaaS tenant isolation material. In the silo model, each tenant runs a fully separate stack and its data is isolated from every other tenant. In the pool model, tenants share some or all of the underlying infrastructure; that is where the efficiency and cost benefits live, and it is also where isolation stops being a network or IAM boundary and becomes something your design has to produce. The bridge model mixes the two, for example a shared web tier for everyone with per-tenant application logic and storage underneath.
Treating these as three boxes to choose from is the wrong mental model. Microsoft's architecture guidance frames isolation as a continuum, from sharing nothing to sharing everything, and you can pick a different point on it for each tier of your system. The combination that works most often in practice: one hostname, one application tier, a separate database per tenant. That pattern, which the guidance calls horizontal partitioning, targets the noisy neighbor problem specifically, since the component absorbing most of the load is usually the database.
The decision is commercial before it is technical
The questions that settle this don't appear on an architecture diagram. How many customers do you expect in three years? Will your customers accept every form of sharing, or does the sentence "our data must not sit in the same database as another company's" come up during contract review? How large is your operations team, and how much of the infrastructure work can you automate? Have you committed to an availability target?
That last point is the one silo deployments quietly punish. Per-customer installs turn every new sale into a small project unless provisioning is fully described in code, and every release multiplies the update work by the number of tenants. The cost is invisible at five customers and eats half your team's week at twenty. If you go silo, infrastructure as code is a precondition rather than a preference.
Four options at the data layer, with different ceilings
Azure SQL Database's comparison of multitenant patterns separates them cleanly. A standalone app with its own database gives the strongest isolation at the highest cost, because each database must be sized for its own peak, and the pattern realistically tops out in the hundreds of tenants. A shared app with a database per tenant keeps isolation strong, brings cost back down through resource pooling, and allows schema customization for an individual tenant; this pattern is manageable into the hundred-thousand-database range. A single shared multitenant database gives the lowest cost per tenant and the weakest isolation. A sharded multitenant model, where each shard holds many tenants and all of one tenant's data lives on a single shard, scales for practical purposes without limit.
The fourth option bills you in operations. You need a catalog that records which tenant lives on which shard, plus procedures to add, split and merge shards and to move tenants between them. It also constrains your schema: the tenant identifier has to be the leading element of the primary key on sharded tables, or the tooling cannot locate and move a tenant's rows. The second option has a hidden cost that is just arithmetic. A single database holding 1,000 tenants with 20 indexes becomes 20,000 indexes once you split it into 1,000 databases. At that count, index maintenance has to be handed to automated tuning, because nobody is doing it by hand.
Schema per tenant: the middle road with a delayed invoice
Teams on PostgreSQL often reach for a fifth option: one database, one schema per tenant. The appeal is real. A single connection pool covers everything, the separation is visible, and dumping one tenant's data is trivial. The invoice arrives with your next migration.
The problem isn't the number of schemas, it's that every schema change happens once per tenant. A migration that takes two seconds alone becomes a release step approaching half an hour at 800 tenants, and when it fails halfway you are left running two different schema versions at once. The system catalog grows with tenants multiplied by tables, and the planner consults that catalog on every query. Any cross-tenant report turns into a query that unions N schemas. Up to a few hundred tenants this model holds, provided you have migration orchestration that runs sequentially and can resume where it stopped. If your target is thousands, deciding early between database-per-tenant and shared tables keyed by tenant is the cheaper route.
Row level security looks enabled, and may not be working
When tenants share tables, PostgreSQL row level security is the way to push isolation down into the database, and it works well when set up correctly. It has three quiet failure modes, all documented.
The first is ownership. Enabling RLS on a table with no policy denies everything by default, which is the good news. The bad news: superusers, roles carrying the BYPASSRLS attribute, and the table's owner bypass policies by default. Applications usually connect with the role that ran the migrations, which makes them the owner, so the policies exist and never apply. The fix is either FORCE ROW LEVEL SECURITY on the table or connecting the application as a separate role that owns nothing.
The second is integrity checks. Unique, primary key and foreign key checks always bypass row security in order to protect data integrity. The PostgreSQL documentation notes that this can create a covert channel: try to insert an email address into a column that is unique across the whole table, and the duplicate key error tells you the value is already in use by another tenant. Scoping unique constraints per tenant, as in (tenant_id, email), closes that leak.
The third is connection pooling. The common pattern stores the tenant identifier in a session variable and has the policy read it back with current_setting. If you run PgBouncer in transaction pooling mode, SET and RESET are not supported there, and a value set outside a transaction can stay on a server connection that gets handed to a different tenant. Set tenant context with SET LOCAL inside the transaction that runs the query. Learn how policies combine while you are there: permissive policies are OR'ed together, restrictive policies are AND'ed. A second permissive policy added so that "admins can see everything" widens the tenant boundary rather than tightening it.
Isolation and authorization are different problems
Missing that distinction produces the bug class that ranked first in the OWASP API Security Top 10 in 2019 and stayed first in the 2023 edition: broken object level authorization. Role-based authorization answers what a user may do. Isolation answers whose data they may touch. One says "this user can view invoices," the other says "only their own company's invoices." Skipping the second check on a single endpoint is enough.
The rule that follows is short: derive the tenant identifier from the authentication result, meaning the token, and never trust a tenant identifier that arrives in a path, query string, header or request body. AWS's SaaS guidance goes a step further and enforces the boundary one layer below application code, reading tenant context from the JWT and then acquiring tenant-scoped credentials, with the mechanics hidden inside wrappers so it is not something each developer has to remember. The PostgreSQL equivalent is RLS plus a data access layer that refuses to build a query without tenant scope.
Write the test down too. The method OWASP describes is as simple as it sounds: create accounts in two separate tenants, use tenant A's token to request tenant B's object IDs, and expect a 403 or 404. Make that a regression test in the pipeline rather than a one-off audit, which is where it fits in the test automation pyramid. Endpoint-level examples live in our post on API security and the OWASP API Top 10.
Noisy neighbors and blast radius
The most concrete risk in a shared database is one tenant's heavy report degrading response times for everyone. Cloud provider documentation is blunt about this: a shared database offers no built-in way to monitor or manage an individual tenant's resource consumption, so you have to build that measurement in the application layer. Track at least three numbers per tenant: query time per request, rows scanned, and storage.
With those in hand you have levers. Per-tenant rate limits, a separate queue for heavy jobs, reporting moved to a read replica, and lifting your heaviest tenant into its own database. The second risk sits on the deployment side. In a pooled model, a bad release or a bad migration reaches every customer at the same instant, while a silo or stamp model lets you advance the same change tenant by tenant. Progressive rollout and rollback steps are covered in CI/CD for small teams, and setting the thresholds that tell you it went wrong in observability, SLOs and error budgets.
Can you restore one customer to yesterday afternoon?
This question often ends the architecture debate on its own. With a database per tenant, the answer is easy: restore that database to a point in time and no other customer notices. When tenants share tables, restoring the database restores everyone. The honest answer there is to deliberately build a tenant-scoped export and import path, then rehearse it.
The same machinery serves four separate needs: handing a customer their complete data at the end of a contract, answering deletion and portability requests (some of which carry statutory deadlines), moving one tenant off the shared database onto its own, and cloning a real tenant into a test environment. If those paths don't exist, none of them exist, and all four end up written under pressure the first time they are needed. The gap between having a backup and being able to come back is covered in the 3-2-1 backup rule, and who owns the data and the code in source code ownership and escrow.
If you don't measure cost per tenant, you don't know your price
In a silo model, a customer's cost is legible on the bill. In a pooled model it isn't, and AWS lists exactly this among the known drawbacks of sharing: attributing consumption to individual tenants requires you to instrument your own system. Without that measurement, pricing tiers get set by guesswork and the customer eating your margin stays invisible.
Getting started doesn't require a cost analytics platform. Put the tenant identifier on every request log, roll up storage and total query time per tenant weekly, and list your top ten each month. That list produces two decisions: which customer should be moved to its own database, and where the price tiers should break. It has a side benefit as well, since "we want our own server" stops being an argument and becomes a priced option.
Put a tenant ID in the schema even with one customer
This is the cheapest insurance available. Azure's hybrid model rests on it: every database carries the tenant identifier in its schema, and some of them happen to hold exactly one tenant. Because the number of tenants in a database has no effect on the schema, moving a customer from a shared database to a dedicated one and back becomes routine work. Trial tenants sit in the shared database, and a customer who upgrades gets moved out.
Two things belong alongside it. First, a mapping table recording which deployment hosts which tenant; without that record you cannot route a request correctly or work out who to notify during an incident. Second, a codebase that supports both modes at once, which is the actual risk of running a mixed model. Adding a tenant identifier to a schema that has been live for four years costs far more than changing infrastructure. Adding the column today costs almost nothing.
Five things you can do this week
One: write down your definition of a tenant. Is the tenant the customer company, a department inside it, or does the same customer's test and production environment count as two tenants? Every later decision is guesswork until that is settled. Two: if you use RLS, run an experiment with the role your application connects as and try to read another tenant's row; if rows come back, you are probably connecting as the table owner. Three: write a cross-tenant access test with two tenants and swapped identifiers, and put it in the pipeline. Four: run the "restore customer X to 2pm yesterday" drill and time it. Five: add the tenant identifier to request logs and rank tenants by query time and storage.
What those five produce is a clear picture of what your current model actually guarantees, which you need before arguing about which model to move to. Isolation you cannot demonstrate is a more urgent problem than the choice of architecture. When you do get to the choice, starting from a list of criteria beats starting from a diagram, the same framing we used for monolith versus microservices.
Need help with this topic?