<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aswani Kumar</title>
    <description>The latest articles on DEV Community by Aswani Kumar (@ashwin31).</description>
    <link>https://dev.to/ashwin31</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3799673%2F51ecf151-19a9-4868-9272-5583aa83b159.jpg</url>
      <title>DEV Community: Aswani Kumar</title>
      <link>https://dev.to/ashwin31</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashwin31"/>
    <language>en</language>
    <item>
      <title>I Built a Multi-Tenant CRM with PostgreSQL Row-Level Security — Here's What I Learned</title>
      <dc:creator>Aswani Kumar</dc:creator>
      <pubDate>Sun, 01 Mar 2026 09:07:11 +0000</pubDate>
      <link>https://dev.to/ashwin31/i-built-a-multi-tenant-crm-with-postgresql-row-level-security-heres-what-i-learned-27c5</link>
      <guid>https://dev.to/ashwin31/i-built-a-multi-tenant-crm-with-postgresql-row-level-security-heres-what-i-learned-27c5</guid>
      <description>&lt;p&gt;Every CRM I've ever used has made me angry.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I'm Ashwin Kumar. I ran &lt;a href="https://micropyramid.com" rel="noopener noreferrer"&gt;MicroPyramid&lt;/a&gt;, 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:&lt;/p&gt;

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

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;That became &lt;a href="https://github.com/MicroPyramid/Django-CRM" rel="noopener noreferrer"&gt;Django-CRM&lt;/a&gt; (now &lt;strong&gt;BottleCRM&lt;/strong&gt;). 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Multi-Tenancy Problem
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate databases per tenant&lt;/strong&gt; — bulletproof isolation, operational nightmare&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-per-tenant&lt;/strong&gt; (the &lt;code&gt;django-tenants&lt;/code&gt; approach) — each tenant gets a PostgreSQL schema, shared database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared schema with Row-Level Security&lt;/strong&gt; — one schema, one set of tables, PostgreSQL enforces isolation at the row level&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I started, &lt;code&gt;django-tenants&lt;/code&gt; was the obvious choice. It's mature, well-documented, and the Django community loves it. I almost went with it.&lt;/p&gt;

&lt;p&gt;Then I did the math.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Schema-Per-Tenant Broke Down
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;django-tenants&lt;/code&gt;, every &lt;code&gt;migrate&lt;/code&gt; runs against every schema. Got 500 tenants? That's 500 migration runs. Add a column? Hope you've got time.&lt;/p&gt;

&lt;p&gt;Here's what we actually hit in production with schema-per-tenant on a previous project:&lt;/p&gt;

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

&lt;p&gt;RLS flips the model entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Row-Level Security: Let PostgreSQL Do the Hard Work
&lt;/h2&gt;

&lt;p&gt;With RLS, every table has a policy that says: "You can only see rows where &lt;code&gt;org_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Here's what an actual RLS policy looks like for our &lt;code&gt;leads&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Enable RLS on the table&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;leads_lead&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Force RLS even for table owners&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;leads_lead&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- The policy: users can only see rows belonging to their org&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation_policy&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;leads_lead&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_org'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Separate policy for INSERT (ensure users can only create rows for their org)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_insert_policy&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;leads_lead&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_org'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Once this policy exists, PostgreSQL will &lt;strong&gt;never&lt;/strong&gt; 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 &lt;code&gt;.filter(org=request.org)&lt;/code&gt;. The database says no.&lt;/p&gt;

&lt;p&gt;This is defense in depth that actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Django Talks to RLS
&lt;/h2&gt;

&lt;p&gt;The magic glue is a middleware that sets the PostgreSQL session variable on every request. Here's the actual pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# middleware/tenant.py
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_response&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__call__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Extract org from the authenticated user's JWT
&lt;/span&gt;        &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;org&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET app.current_org = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Reset to prevent connection pool leakage
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RESET app.current_org&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — no tenant filtering needed!
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LeadViewSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelViewSet&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;serializer_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LeadSerializer&lt;/span&gt;
    &lt;span class="n"&gt;permission_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_queryset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# This returns ALL leads — but RLS ensures
&lt;/span&gt;        &lt;span class="c1"&gt;# only the current org's leads come back
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-created_at&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# org_id is set automatically, and RLS validates
&lt;/span&gt;        &lt;span class="c1"&gt;# that it matches the session variable
&lt;/span&gt;        &lt;span class="n"&gt;serializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;created_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;.filter(org=request.org)&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Stack: Why Django + SvelteKit
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bundle size.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reactivity without boilerplate.&lt;/strong&gt; Svelte 5's runes (&lt;code&gt;$state&lt;/code&gt;, &lt;code&gt;$derived&lt;/code&gt;) are cleaner than &lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt; + &lt;code&gt;useMemo&lt;/code&gt;. For a data-heavy app, this matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SvelteKit's routing is file-based and simple.&lt;/strong&gt; No fighting with App Router vs Pages Router. No server components confusion. It just works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Django 5 + DRF&lt;/td&gt;
&lt;td&gt;Battle-tested, ORM is excellent for complex data models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;SvelteKit 2 + Svelte 5 + TailwindCSS 4&lt;/td&gt;
&lt;td&gt;Fast, small, great DX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL 14+&lt;/td&gt;
&lt;td&gt;RLS, JSONB, full-text search — all in one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;JWT (access + refresh tokens)&lt;/td&gt;
&lt;td&gt;Stateless, works with mobile apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background jobs&lt;/td&gt;
&lt;td&gt;Celery + Redis&lt;/td&gt;
&lt;td&gt;Email, notifications, data imports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Docker Compose&lt;/td&gt;
&lt;td&gt;One command: &lt;code&gt;docker compose up&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Performance: RLS vs Schema-Per-Tenant
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;Query performance:&lt;/strong&gt; Virtually identical. PostgreSQL's query planner treats RLS policies like additional &lt;code&gt;WHERE&lt;/code&gt; clauses. With a proper index on &lt;code&gt;org_id&lt;/code&gt;, there's negligible overhead — we measured &amp;lt;1ms difference on 95th percentile queries.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Connection pooling:&lt;/strong&gt; Standard PgBouncer works perfectly. No &lt;code&gt;search_path&lt;/code&gt; switching, no stale state. Each request just sets a session variable and resets it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk usage:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff?&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MIT and Not AGPL
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons From Running This With Real Clients
&lt;/h2&gt;

&lt;p&gt;After years of running multi-tenant CRM deployments, here's what I wish someone had told me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Always reset the session variable.&lt;/strong&gt; If you set &lt;code&gt;app.current_org&lt;/code&gt; 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 &lt;code&gt;finally&lt;/code&gt; block or middleware teardown.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;3. Test with multiple tenants from day one.&lt;/strong&gt; 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.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;BottleCRM runs with a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml (simplified)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./backend&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgres://crm:crm@db:5432/bottlecrm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REDIS_URL=redis://redis:6379&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SECRET_KEY=${SECRET_KEY}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;

  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./frontend&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUBLIC_API_URL=http://backend:8000&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=bottlecrm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=crm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=crm&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;

  &lt;span class="na"&gt;celery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./backend&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;celery -A crm worker -l info&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/MicroPyramid/Django-CRM.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Django-CRM
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Open &lt;code&gt;localhost:3000&lt;/code&gt;, create an account, and you've got a production-grade CRM running locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship It
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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




&lt;p&gt;&lt;strong&gt;⭐ &lt;a href="https://github.com/MicroPyramid/Django-CRM" rel="noopener noreferrer"&gt;Star Django-CRM on GitHub&lt;/a&gt;&lt;/strong&gt; — 2,100+ stars and growing&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🚀 &lt;a href="https://bottlecrm.io" rel="noopener noreferrer"&gt;Try the live demo at bottlecrm.io&lt;/a&gt;&lt;/strong&gt; — free, no credit card&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🤝 Contribute&lt;/strong&gt; — we're looking for help with the mobile app (Flutter), analytics dashboard, and documentation. Check the issues tab.&lt;/p&gt;

&lt;p&gt;Built with 🐍 Django + ⚡ SvelteKit + 🐘 PostgreSQL. MIT Licensed. Free forever.&lt;/p&gt;

</description>
      <category>django</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>crm</category>
    </item>
  </channel>
</rss>
