A schema for five apps, not five schemas.
Why we put CRM, KB, CMS, Outreach, and Conversations behind a single Postgres — and how row-level security carries the load.

Most B2B platforms grow by acquisition: each module ships with its own schema, its own auth model, its own idea of what a "contact" is. After a few years, the integration layer eats the engineering team.
Munin took the opposite bet. CRM, KB, CMS, Outreach, and Conversations all share one Postgres database, one identity model, one tenancy column, and one set of org_id-scoped RLS policies. This post is about what that's actually like to build on, and where it bites.
One schema, five surfaces
The core insight is that the customer record is one record. A contact in CRM is the same contact the helpdesk is talking to and the same contact a campaign targets. Splitting that into five rows in five schemas isn't an architecture — it's a synchronization project waiting to happen.
So we don't. There's exactly one contacts table, exactly one org_id on it, exactly one set of RLS policies. The CRM module reads it. The Conversations module reads it. The Outreach module reads it. They all see the same row, with the same scopes.
Row-level security as the contract
RLS gets a bad rap because most teams that try it bolt it on after the fact. The trick is to treat it as the contract, not the safety net. Every query goes through Postgres's RLS engine; the application code can't bypass it, even by accident, because the database itself is enforcing the policy.
This means our application code doesn't filter by org_id. It can't, because the queries don't include it. The database does, on every read and write, using the session's GUC-scoped tenant context. The application's job is to set the context correctly; the database's job is to refuse to do anything else.
Where it bites
It's not free. Three things hurt:
- Migrations are global. A change to the contact schema ripples through every module. We coordinate.
- RLS policy debugging is its own skill. When a query returns nothing and you don't know why, the first thing to check is whether the session GUC is set.
- Performance. RLS predicates show up in every plan. We've spent more time on
org_id-prefixed indexes than I expected.
None of these are dealbreakers. All of them are cheaper than running five schemas.
What we'd do again
All of it. The synchronization problems we're not having are worth the migration coordination we are. The customer is one person; the record should be too.