DEV Community

Aswani Kumar
Aswani Kumar

Posted on

I Built a Multi-Tenant CRM with PostgreSQL Row-Level Security — Here's What I Learned

Every CRM I've ever used has made me angry.

Not the "this button is in the wrong place" kind of angry. The "I'm paying $65/user/month and I can't even export my own data" kind of angry.

I'm Ashwin Kumar. I ran MicroPyramid, a software consulting firm with 100+ engineers and clients across the US, Australia, Israel, and the UK. Over a decade, we built custom CRMs, ERPs, and SaaS platforms for dozens of companies. And every single time, the conversation started the same way:

"We're using Salesforce/HubSpot/Zoho, but it's too expensive, too locked-in, and we can't customize it."

So we'd build them something custom. Again and again. After the fifth time building essentially the same multi-tenant CRM from scratch, I thought — why not build it once, properly, and open-source it?

That became Django-CRM (now BottleCRM). It's got 2,100+ stars on GitHub, it's MIT-licensed, and it runs in production. But the most interesting technical decision — the one I want to talk about — is how we handle multi-tenancy.

The Multi-Tenancy Problem

If you're building any SaaS, you need multi-tenancy. Multiple organizations sharing the same application, each seeing only their own data. There are three common approaches:

  1. Separate databases per tenant — bulletproof isolation, operational nightmare
  2. Schema-per-tenant (the django-tenants approach) — each tenant gets a PostgreSQL schema, shared database
  3. Shared schema with Row-Level Security — one schema, one set of tables, PostgreSQL enforces isolation at the row level

When I started, django-tenants was the obvious choice. It's mature, well-documented, and the Django community loves it. I almost went with it.

Then I did the math.

Why Schema-Per-Tenant Broke Down

With django-tenants, every migrate runs against every schema. Got 500 tenants? That's 500 migration runs. Add a column? Hope you've got time.

Here's what we actually hit in production with schema-per-tenant on a previous project:

  • Migration time scaled linearly with tenant count. 200 tenants meant 45+ minute deploys.
  • Connection pooling was painful. PgBouncer doesn't love SET search_path on every request. We burned hours debugging stale connections.
  • Cross-tenant queries were nearly impossible. Want to build an admin dashboard showing all tenants? You're writing raw SQL with UNION ALL across schemas.
  • Django's ORM assumed one schema. We were constantly fighting the framework instead of using it.

RLS flips the model entirely.

Row-Level Security: Let PostgreSQL Do the Hard Work

With RLS, every table has a policy that says: "You can only see rows where org_id matches the current session variable." PostgreSQL enforces this at the engine level — not in your application code, not in your ORM, not in a middleware hack. At the database engine level.

Here's what an actual RLS policy looks like for our leads table:

-- Enable RLS on the table
ALTER TABLE leads_lead ENABLE ROW LEVEL SECURITY;

-- Force RLS even for table owners
ALTER TABLE leads_lead FORCE ROW LEVEL SECURITY;

-- The policy: users can only see rows belonging to their org
CREATE POLICY tenant_isolation_policy ON leads_lead
    USING (org_id = current_setting('app.current_org')::INTEGER);

-- Separate policy for INSERT (ensure users can only create rows for their org)
CREATE POLICY tenant_insert_policy ON leads_lead
    FOR INSERT
    WITH CHECK (org_id = current_setting('app.current_org')::INTEGER);
Enter fullscreen mode Exit fullscreen mode

That's it. Once this policy exists, PostgreSQL will never return a row from another tenant, even if your application code has a bug. Even if someone writes a raw SQL query. Even if a junior developer forgets to add .filter(org=request.org). The database says no.

This is defense in depth that actually works.

How Django Talks to RLS

The magic glue is a middleware that sets the PostgreSQL session variable on every request. Here's the actual pattern:

# middleware/tenant.py

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # Extract org from the authenticated user's JWT
        org_id = getattr(request, 'org', None)

        if org_id:
            from django.db import connection
            with connection.cursor() as cursor:
                cursor.execute(
                    "SET app.current_org = %s", [str(org_id)]
                )

        response = self.get_response(request)

        # Reset to prevent connection pool leakage
        if org_id:
            with connection.cursor() as cursor:
                cursor.execute("RESET app.current_org")

        return response
Enter fullscreen mode Exit fullscreen mode

The beauty: your views and serializers don't need to know about tenancy at all. The ORM just works. Querysets automatically return only the current tenant's data because PostgreSQL filters at the engine level.

# views.py — no tenant filtering needed!

class LeadViewSet(viewsets.ModelViewSet):
    serializer_class = LeadSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        # This returns ALL leads — but RLS ensures
        # only the current org's leads come back
        return Lead.objects.all().order_by('-created_at')

    def perform_create(self, serializer):
        # org_id is set automatically, and RLS validates
        # that it matches the session variable
        serializer.save(
            org_id=self.request.org.id,
            created_by=self.request.user
        )
Enter fullscreen mode Exit fullscreen mode

No .filter(org=request.org) scattered across 200 views. No chance of forgetting it in one place and leaking data. The database is the single source of truth for tenant isolation.

The Full Stack: Why Django + SvelteKit

A lot of people ask why not Next.js or React. Short answer: I've shipped production apps with all of them, and for a CRM, SvelteKit wins on three fronts:

  1. Bundle size. CRMs have dozens of forms, tables, and modals. Svelte compiles away the framework — our JS bundle is ~40% smaller than the React equivalent we prototyped.
  2. Reactivity without boilerplate. Svelte 5's runes ($state, $derived) are cleaner than useState + useEffect + useMemo. For a data-heavy app, this matters.
  3. SvelteKit's routing is file-based and simple. No fighting with App Router vs Pages Router. No server components confusion. It just works.

The full stack:

Layer Tech Why
Backend Django 5 + DRF Battle-tested, ORM is excellent for complex data models
Frontend SvelteKit 2 + Svelte 5 + TailwindCSS 4 Fast, small, great DX
Database PostgreSQL 14+ RLS, JSONB, full-text search — all in one
Auth JWT (access + refresh tokens) Stateless, works with mobile apps
Background jobs Celery + Redis Email, notifications, data imports
Deployment Docker Compose One command: docker compose up

Performance: RLS vs Schema-Per-Tenant

Let's talk numbers. These are from our benchmarks with realistic data (10K-100K rows per tenant):

Query performance: Virtually identical. PostgreSQL's query planner treats RLS policies like additional WHERE clauses. With a proper index on org_id, there's negligible overhead — we measured <1ms difference on 95th percentile queries.

Migration speed: This is where RLS dominates. One schema means one migration run, regardless of tenant count. 500 tenants? Same 3-second migration as 1 tenant.

Connection pooling: Standard PgBouncer works perfectly. No search_path switching, no stale state. Each request just sets a session variable and resets it.

Disk usage: Shared indexes across all tenants. Schema-per-tenant duplicates every index for every tenant. At 500 tenants with 15 tables, that's 7,500 indexes vs. 15.

The tradeoff? Schema-per-tenant gives you harder isolation boundaries. If a tenant needs a custom column or a different schema, schema-per-tenant handles that natively. With RLS, every tenant shares the same schema. For a CRM where every tenant uses the same data model, this isn't a limitation — it's a feature.

Why MIT and Not AGPL

This was a deliberate choice. AGPL would have "protected" the project by forcing anyone who modifies it to open-source their changes. But in practice:

  • AGPL scares enterprises. Legal teams see AGPL and close the tab. We wanted adoption.
  • MIT builds trust. You can fork it, modify it, sell it, host it — we don't care. Use it however you want.
  • Community over control. The projects with the healthiest ecosystems — React, Vue, Django itself — are permissively licensed. We wanted contributors, not compliance officers.

Our bet: if the product is good enough, people contribute because they want to, not because a license forces them. So far, that bet is paying off.

Lessons From Running This With Real Clients

After years of running multi-tenant CRM deployments, here's what I wish someone had told me:

1. Always reset the session variable. If you set app.current_org and don't reset it, connection pools will serve the wrong tenant's data to the next request. We learned this the hard way. Always reset in a finally block or middleware teardown.

2. Superuser bypasses RLS by default. PostgreSQL RLS policies don't apply to superusers or table owners unless you use FORCE ROW LEVEL SECURITY. We missed this initially and wondered why our admin panel showed all data. Add FORCE to every table.

3. Test with multiple tenants from day one. Write a test that creates two orgs, inserts data for both, and asserts that each org can only see its own. Run it in CI. Never skip it.

4. Audit logging needs the org context too. Your audit trail should capture which org performed which action. Don't rely on application-level logging alone — use PostgreSQL triggers that read current_setting('app.current_org').

5. Data exports are trivially simple. Need to export all of tenant 42's data? SET app.current_org = '42'; COPY leads_lead TO '/tmp/leads.csv' CSV HEADER; — RLS does the filtering for you.

Getting Started

BottleCRM runs with a single command:

# docker-compose.yml (simplified)
services:
  backend:
    build: ./backend
    environment:
      - DATABASE_URL=postgres://crm:crm@db:5432/bottlecrm
      - REDIS_URL=redis://redis:6379
      - SECRET_KEY=${SECRET_KEY}
    depends_on:
      - db
      - redis

  frontend:
    build: ./frontend
    environment:
      - PUBLIC_API_URL=http://backend:8000
    ports:
      - "3000:3000"

  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=bottlecrm
      - POSTGRES_USER=crm
      - POSTGRES_PASSWORD=crm
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

  celery:
    build: ./backend
    command: celery -A crm worker -l info
    depends_on:
      - db
      - redis

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode
git clone https://github.com/MicroPyramid/Django-CRM.git
cd Django-CRM
docker compose up
Enter fullscreen mode Exit fullscreen mode

That's it. Open localhost:3000, create an account, and you've got a production-grade CRM running locally.

Ship It

Building BottleCRM taught me that the best multi-tenancy strategy isn't the most clever one — it's the one that lets PostgreSQL do what it's good at. RLS pushes tenant isolation into the database engine where it belongs. Your application code stays clean, your deployments stay fast, and your data stays safe.

If you're building a multi-tenant Django app, seriously consider RLS before reaching for django-tenants. It's less magical, more explicit, and PostgreSQL has been battle-testing this feature since version 9.5.


Star Django-CRM on GitHub — 2,100+ stars and growing

🚀 Try the live demo at bottlecrm.io — free, no credit card

🤝 Contribute — we're looking for help with the mobile app (Flutter), analytics dashboard, and documentation. Check the issues tab.

Built with 🐍 Django + ⚡ SvelteKit + 🐘 PostgreSQL. MIT Licensed. Free forever.

Top comments (0)