Multi-tenant from day one: why it's the only right call for SMB SaaS
Adding multi-tenancy later is like rebuilding the foundation. Why we designed Omnirago as multi-tenant from day one, and our Postgres row-level security approach.
The single most common mistake teams building SMB SaaS make: deferring multi-tenancy.
The reasoning goes: “Let’s build for one customer first, then scale once we find product-market fit.” The problem is that “later” never comes. When it does, it means a practical rewrite — adding WHERE tenant_id = ? to every query and praying.
We designed Omnirago as multi-tenant from day one. This post covers the decisions, tradeoffs, and our Postgres RLS approach.
Three multi-tenancy models
There are three well-known models:
- Single database, shared schema. One Postgres DB, every table has a
tenant_idcolumn. Most common. - Single database, schema-per-tenant. A separate Postgres schema per tenant. Mid-level isolation.
- Database-per-tenant. A separate database per tenant. Maximum isolation, maximum ops cost.
For SMB SaaS, #1 is the right answer. Why: SMB tenant data size (typically 50K-500K records) fits comfortably in one database. Schema-per-tenant leads to migration hell; database-per-tenant means 1000 backups, 1000 connection pools, and 1000 separate patch schedules at 1000 tenants.
Row-Level Security: pushing protection to the DB
The fear with single-database + tenant_id is simple: one day someone forgets WHERE tenant_id = ?, and tenant A sees tenant B’s data. This is the side effect of trusting the application layer.
Postgres Row-Level Security (RLS) delegates that trust to the database. It works like this:
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON customers
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Now every query returns only the tenant whose ID is set in app.current_tenant. Even if a developer forgets, the database stops them.
On the application side, set the session variable at the start of each request:
await pg.query(`SET LOCAL app.current_tenant = '${tenantId}'`);
Since SET LOCAL is transaction-scoped, there’s no pool-level leakage.
The connection pool / SET LOCAL dilemma
If you’re using PgBouncer in transaction-mode pooling, plain SET is dangerous (session-level state can land on the wrong connection). SET LOCAL is safe because it lives inside the transaction.
Practical rule: every HTTP request starts a transaction, sets the tenant with SET LOCAL, runs queries, and commits. A middleware automates this; the developer never thinks about it.
Admin access
Your support team will need to see data across all tenants to debug. The right way to bypass RLS is a dedicated database role:
ALTER ROLE app_admin BYPASSRLS;
The support tool connects as app_admin and can see all tenants; the end-user app cannot disable RLS at all. Make sure your audit log captures admin access.
Holding/group structures: subaccounts
A meaningful portion of SMBs in Türkiye run as holdings. A textile group has 3 brands, each brand has its own dealer network, with consolidated financials — one tenant_id is not enough.
In Omnirago we use a hierarchical tenant model: tenant.parent_tenant_id as a self-referencing FK. The RLS policy then runs through a function that computes which tenants a user can access:
CREATE POLICY tenant_hierarchy ON customers
USING (tenant_id IN (
SELECT id FROM accessible_tenants(current_setting('app.current_user')::uuid)
));
The holding admin sees all child tenants; the brand manager sees only their brand.
Multi-currency, multi-language — per tenant
A natural consequence of multi-tenancy: each tenant can have its own currency, tax regime, and language preferences. Storing these on the tenant table and loading them at request boundary is the cleanest way to avoid the “global state” anti-pattern.
An SMB exporter must be able to issue invoices in TRY, USD, and EUR simultaneously; communicate in English with foreign customers and Turkish with local dealers. This configuration falls out naturally when tenant config combines with user session.
Cost: how much really?
The cost of doing multi-tenant from day one:
tenant_idcolumn + index per table → minimal disk + minimal query cost- RLS policy evaluation → 2-5% extra query overhead (measured, unnoticeable)
SET LOCALper connection → negligible
The cost of doing multi-tenant later: reviewing every query, adding tenant_id to every cache key, adapting every background job, and doing it without breaking production traffic for 3-6 months.
That delta is what “doing the right thing at the right time in the right order” means for teams building SMB SaaS.
Next up
In the next post I’ll cover how Omnirago handles WhatsApp Business API rate limits and how we segment per-tenant quotas.
Ragomind builds AI-powered B2B software for SMBs. For engineering partnerships: hello@ragomind.com.