DEV Community

Cover image for Building Production-Ready Multi-Tenant SaaS in Rust with Actix-web
chinedu
chinedu

Posted on • Originally published at therustguy.com

Building Production-Ready Multi-Tenant SaaS in Rust with Actix-web

How I built tenant isolation for SmartFarmAI a poultry farm management platform serving farms across Nigeria and Tanzania.

I've been building SmartFarmAI, an AI-powered poultry farm management platform, and one of the hardest architectural decisions I had to make early on was: how do I safely isolate data between farms?

When a farmer in Lagos logs in and checks their egg production numbers, they should never under any circumstance see data from a 60,000-bird enterprise operation in Tanzania. One bug, one missed WHERE clause, and you're leaking customer data. In agriculture, that's not just a privacy issue it's a business-ending trust violation.

This article walks through exactly how I solved this using Rust, Actix-web, and PostgreSQL Row-Level Security (RLS) the patterns, the gotchas I hit in production, and the code that keeps it all safe.

If you're building any kind of multi-tenant SaaS in Rust, this should save you weeks of trial and error.

The pattern in a nutshell: Actix middleware extracts the tenant from each request, starts a DB transaction, calls SET LOCAL app.current_tenant_id, and lets Postgres RLS policies automatically scope every query no manual WHERE org_id = $1 needed.

This article assumes you're comfortable with Rust basics, Actix-web routing, and have at least heard of PostgreSQL Row-Level Security. If you've never touched RLS before, you'll still be able to follow along I introduce the concepts as we go.

Multi-Tenancy: Pick Your Strategy

Before writing any code, you have to decide how to isolate tenant data. There are three common approaches:

Database-per-Tenant: Architecture diagram illustrating the database-per-tenant multi-tenancy pattern and its operational challenges. A central PostgreSQL cluster connects to many separate databases, DB Tenant 1 through DB Tenant 6 and beyond to 100+, each represented as individual database icons spreading across the top of the diagram. Red arrows on the left depict a management and migration nightmare, showing how update scripts must be rolled out individually to every tenant database with clock icons indicating time delays. Green arrows on the right show the benefit of maximum isolation, with each tenant receiving completely separate data access. The bottom notes that this approach requires Docker/K8s at scale, complex Prometheus/Grafana monitoring, and coordinated Redis migration tooling for rollouts.

Database-per-tenant: Every customer gets their own PostgreSQL database. Maximum isolation, but a nightmare to manage at scale. Migrations become a coordinated rollout across hundreds of databases.

Schema-per-Tenant: Architecture diagram showing the schema-per-tenant multi-tenancy pattern as a middle ground between shared database and database-per-tenant approaches. A single PostgreSQL instance contains multiple colored schema segments. Schema Tenant 1, Schema Tenant 2, through Schema 100+ represented as pie-slice sections within one database. Red arrows on the left illustrate schema migration pain at scale, as each schema requires individual migration rollout. The top right shows a tangled knot of lines representing connection pooling complexity as the number of schemas grows. Green arrows on the right show isolated data access per tenant. The bottom notes Docker/K8s scaling is better than database-per-tenant, but still requires complex monitoring and coordinated rollout tooling.

Schema-per-tenant: One database, but each customer gets their own schema. Better than separate databases, but schema migrations are still painful and connection pooling gets complicated fast.

Shared Database with RLS:

Shared database with Row-Level Security: One database, one schema, one set of tables but PostgreSQL itself enforces which rows each tenant can see. This is what I chose for SmartFarmAI.

Why RLS? Because it pushes security enforcement down to the database layer. My application code doesn't need to remember to add WHERE org_id = $1 to every query. PostgreSQL does it automatically. If I forget a filter in my Rust code? Doesn't matter the database won't return rows that don't belong to the current tenant.

For an early-stage SaaS serving poultry farmers, this gives me the isolation guarantees I need without the operational overhead of managing hundreds of databases.

The Data Model: org_id Everywhere

SmartFarmAI's domain hierarchy looks like this:

Organization (tenant)
  └── Farm(s)
       └── Batch(es) / Pen House(s)
            └── Egg Records, Mortality Logs, Vaccination Schedules...
Enter fullscreen mode Exit fullscreen mode

The key decision: every table carries org_id, even child tables. You might think "batches already belong to a farm, and farms belong to an org why duplicate org_id on the batch table?" Two reasons:

  1. RLS policies need a direct column reference. PostgreSQL can't follow foreign key chains in a policy expression. The policy needs to see org_id on the row it's evaluating.

  2. Composite foreign keys enforce correctness. A batch doesn't just reference a farm_id it references (org_id, farm_id). This makes it structurally impossible to accidentally associate a batch with a farm from a different organization.

Here's what the schema looks like:

-- Organizations (tenants)
CREATE TABLE organizations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Farms belong to an organization
CREATE TABLE farms (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL REFERENCES organizations(id),
    name TEXT NOT NULL,
    location TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Composite unique for child FK references
    UNIQUE (org_id, id)
);

-- Batches belong to a farm, scoped by org
CREATE TABLE batches (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL,
    farm_id UUID NOT NULL,
    batch_name TEXT NOT NULL,
    bird_count INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Composite FK: can't assign a batch to a farm in a different org
    CONSTRAINT batch_farm_fk
        FOREIGN KEY (org_id, farm_id)
        REFERENCES farms (org_id, id)
        ON DELETE CASCADE,

    UNIQUE (org_id, farm_id, id)
);

-- Egg production records
CREATE TABLE egg_production_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL,
    farm_id UUID NOT NULL,
    batch_id UUID NOT NULL,
    record_date DATE NOT NULL DEFAULT CURRENT_DATE,
    eggs_collected INTEGER NOT NULL CHECK (eggs_collected >= 0),
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT egg_log_batch_fk
        FOREIGN KEY (org_id, farm_id, batch_id)
        REFERENCES batches (org_id, farm_id, id)
        ON DELETE CASCADE,

    -- One record per batch per day
    CONSTRAINT unique_egg_record UNIQUE (batch_id, record_date)
);
Enter fullscreen mode Exit fullscreen mode

Notice the pattern: every child table has a composite foreign key that threads org_id all the way down. This is intentional. It's one of those things that feels redundant until it saves you from a data leak.

Enabling Row-Level Security

With the schema in place, enabling RLS is straightforward:

-- Enable RLS on all tenant-scoped tables
ALTER TABLE farms ENABLE ROW LEVEL SECURITY;
ALTER TABLE batches ENABLE ROW LEVEL SECURITY;
ALTER TABLE egg_production_logs ENABLE ROW LEVEL SECURITY;

-- Create isolation policies
CREATE POLICY tenant_isolation ON farms
    USING (org_id = current_setting('app.current_tenant_id')::uuid);

CREATE POLICY tenant_isolation ON batches
    USING (org_id = current_setting('app.current_tenant_id')::uuid);

CREATE POLICY tenant_isolation ON egg_production_logs
    USING (org_id = current_setting('app.current_tenant_id')::uuid);

-- WITH CHECK ensures inserts/updates also respect the tenant boundary
CREATE POLICY tenant_write_isolation ON farms
    FOR INSERT
    WITH CHECK (org_id = current_setting('app.current_tenant_id')::uuid);

CREATE POLICY tenant_write_isolation ON batches
    FOR INSERT
    WITH CHECK (org_id = current_setting('app.current_tenant_id')::uuid);

CREATE POLICY tenant_write_isolation ON egg_production_logs
    FOR INSERT
    WITH CHECK (org_id = current_setting('app.current_tenant_id')::uuid);
Enter fullscreen mode Exit fullscreen mode

The magic here is current_setting('app.current_tenant_id'). This is a PostgreSQL session variable not a table, not a function, just a key-value pair that lives for the duration of a transaction or session. Before any query runs, we set this variable to the current user's organization ID. PostgreSQL's RLS engine then uses it to filter every SELECT, INSERT, UPDATE, and DELETE.

Important: RLS policies are enforced for regular users but not for the table owner or superusers. Make sure your application connects as a non-superuser role.

The Middleware: Setting Tenant Context in Actix-web

This is where Rust and Actix-web come in. Every authenticated request needs to:

  1. Extract the tenant ID (from JWT claims)

  2. Set the PostgreSQL session variable before any query runs

  3. Ensure the variable is cleared when the request ends

Here's how I built it:

The TenantId Extractor

First, a newtype for tenant IDs. This prevents accidentally passing a user ID where an org ID is expected Rust's type system catches it at compile time.

use uuid::Uuid;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TenantId(Uuid);

impl TenantId {
    pub fn new(id: Uuid) -> Self {
        Self(id)
    }

    pub fn as_uuid(&self) -> &Uuid {
        &self.0
    }
}

impl std::fmt::Display for TenantId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}
Enter fullscreen mode Exit fullscreen mode

Extracting Tenant from JWT

In Actix-web, you can implement FromRequest to automatically extract the tenant ID from the JWT on every request:

use actix_web::{FromRequest, HttpRequest, dev::Payload, Error, HttpMessage};
use std::future::{Ready, ready};

impl FromRequest for TenantId {
    type Error = Error;
    type Future = Ready<Result<Self, Self::Error>>;

    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
        // The auth middleware has already validated the JWT
        // and inserted claims into request extensions
        match req.extensions().get::<AuthClaims>() {
            Some(claims) => ready(Ok(TenantId::new(claims.org_id))),
            None => ready(Err(actix_web::error::ErrorUnauthorized(
                "Missing tenant context"
            ))),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The RLS Middleware

Now the critical piece the middleware that sets app.current_tenant_id before your handler runs:

use actix_web::{dev::ServiceRequest, Error, HttpMessage};
use sqlx::PgPool;

pub async fn set_rls_context(
    pool: &PgPool,
    org_id: &TenantId,
) -> Result<sqlx::Transaction<'_, sqlx::Postgres>, Error> {
    let mut tx = pool.begin().await.map_err(|e| {
        actix_web::error::ErrorInternalServerError(format!("DB error: {}", e))
    })?;

    // SET LOCAL scopes the variable to this transaction only
    sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
        .bind(org_id.to_string())
        .execute(&mut *tx)
        .await
        .map_err(|e| {
            actix_web::error::ErrorInternalServerError(format!("RLS context error: {}", e))
        })?;

    Ok(tx)
}
Enter fullscreen mode Exit fullscreen mode

The critical detail here is the third argument to set_config: true means transaction-local. When the transaction commits or rolls back, the variable is automatically cleared. This is essential for connection pool safety more on that below.

Using It in Handlers

With the extractor and the RLS context function, your route handlers look clean:

use actix_web::{web, HttpResponse, Result};
use sqlx::PgPool;

pub async fn get_farms(
    pool: web::Data<PgPool>,
    tenant: TenantId,
) -> Result<HttpResponse> {
    let mut tx = set_rls_context(&pool, &tenant).await?;

    // No WHERE clause needed — RLS handles filtering
    let farms = sqlx::query_as!(
        Farm,
        "SELECT id, org_id, name, location, created_at FROM farms"
    )
    .fetch_all(&mut *tx)
    .await
    .map_err(actix_web::error::ErrorInternalServerError)?;

    tx.commit().await
        .map_err(actix_web::error::ErrorInternalServerError)?;

    Ok(HttpResponse::Ok().json(farms))
}
Enter fullscreen mode Exit fullscreen mode

Notice: no WHERE org_id = $1. The query selects all farms, but RLS ensures the farmer in Lagos only sees their own farms. The 60,000-bird operation in Tanzania is invisible to them at the database level.

The Gotcha That Almost Burned Me: Connection Pools and Session Variables

Here's the mistake that will bite you if you're not careful.

PostgreSQL session variables persist for the lifetime of a connection. When you use a connection pool (like sqlx's PgPool), connections are reused across requests. If you set app.current_tenant_id with session scope (false as the third argument to set_config), here's what happens:

1. Request from Org-A arrives
2. Connection #5 is checked out from the pool
3. SET app.current_tenant_id = 'org-a-uuid'  (session-scoped)
4. Query runs, returns Org-A's data ✅
5. Connection #5 is returned to the pool

6. Request from Org-B arrives
7. Connection #5 is checked out again
8. But app.current_tenant_id is STILL 'org-a-uuid' !!
9. Query runs... returns Org-A's data to Org-B's user 🚨
Enter fullscreen mode Exit fullscreen mode

This is a data leak. And it's subtle it depends on which connection the pool hands out, so it won't happen consistently in development. It'll show up in production under load.

The fix: always use transaction-local scope.

// ✅ SAFE: Transaction-local — cleared automatically when tx ends
sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
    .bind(org_id.to_string())
    .execute(&mut *tx)
    .await?;

// ❌ DANGEROUS: Session-local — persists after connection returns to pool
sqlx::query("SELECT set_config('app.current_tenant_id', $1, false)")
    .bind(org_id.to_string())
    .execute(&pool)
    .await?;
Enter fullscreen mode Exit fullscreen mode

By wrapping every tenant-scoped operation in a transaction and using set_config(..., true), the variable is automatically cleaned up when the transaction ends. No stale tenant context, no data leaks.

When NOT to Use RLS: Background Workers and the Outbox Pattern

SmartFarmAI uses an outbox pattern for event-driven processing. When a farmer logs egg production, the API handler inserts the record and publishes a domain event to an outbox_events table all in the same transaction. A background worker then picks up events and processes them (updating batch totals, triggering AI predictions, etc.).

Here's the thing: the outbox table should NOT have RLS enabled.

Why? The background worker needs to poll for events across all organizations:

SELECT * FROM outbox_events
WHERE processed_at IS NULL
ORDER BY created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;
Enter fullscreen mode Exit fullscreen mode

If RLS were enabled on this table, the worker would need a tenant context to read events but it doesn't belong to any tenant. It's infrastructure.

My rule of thumb:

Table Type RLS? Why
Business data (farms, batches, eggs) Yes Tenant-scoped, must be isolated
Work queues (outbox_events, email_jobs) No Infrastructure, cross-tenant by design
Audit logs Depends RLS if tenant-facing, no RLS if admin-only

But here's the nuance: when the worker processes an event and needs to update business data, it does need to set the RLS context for that specific event's organization:

pub async fn process_event(
    &self,
    pool: &PgPool,
    event: &OutboxEvent,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut tx = pool.begin().await?;

    // Set RLS context for this event's organization
    sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
        .bind(event.org_id.to_string())
        .execute(&mut *tx)
        .await?;

    // Now we can safely update tenant-scoped tables
    match event.topic.as_str() {
        "EggProductionRecorded" => {
            self.update_batch_totals(&mut tx, event).await?;
        }
        "MortalityRecorded" => {
            self.update_mortality_stats(&mut tx, event).await?;
        }
        _ => {}
    }

    // Mark event as processed
    sqlx::query!(
        "UPDATE outbox_events SET processed_at = NOW() WHERE id = $1",
        event.id
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This is the pattern that took me the longest to get right. The worker reads from an unprotected queue, then enters a tenant context per-event to do the actual work. Each event is processed in its own transaction with its own RLS scope.

I actually hit a bug in production where my outbox table initially had RLS enabled. The worker was fetching zero events even though the table had hundreds queued up. No errors just silent filtering. It took me an embarrassing amount of debugging before I realised the worker had no tenant context set, so RLS was filtering out everything.

Testing Multi-Tenant Isolation

Testing RLS requires more thought than testing regular business logic. You need to verify that tenants genuinely can't see each other's data:

#[tokio::test]
async fn test_tenant_isolation() {
    let pool = setup_test_db().await;

    // Create two organizations
    let org_a = create_test_org(&pool, "Farm Corp A").await;
    let org_b = create_test_org(&pool, "Farm Corp B").await;

    // Create farms under each org (using superuser/direct insert)
    create_test_farm(&pool, &org_a, "Org A Farm").await;
    create_test_farm(&pool, &org_b, "Org B Farm").await;

    // Query as Org A — should only see their farm
    let mut tx = pool.begin().await.unwrap();
    sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
        .bind(org_a.to_string())
        .execute(&mut *tx)
        .await
        .unwrap();

    let farms: Vec<Farm> = sqlx::query_as!(Farm, "SELECT * FROM farms")
        .fetch_all(&mut *tx)
        .await
        .unwrap();

    assert_eq!(farms.len(), 1);
    assert_eq!(farms[0].name, "Org A Farm");

    tx.commit().await.unwrap();

    // Query as Org B — should only see their farm
    let mut tx = pool.begin().await.unwrap();
    sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
        .bind(org_b.to_string())
        .execute(&mut *tx)
        .await
        .unwrap();

    let farms: Vec<Farm> = sqlx::query_as!(Farm, "SELECT * FROM farms")
        .fetch_all(&mut *tx)
        .await
        .unwrap();

    assert_eq!(farms.len(), 1);
    assert_eq!(farms[0].name, "Org B Farm");

    tx.commit().await.unwrap();
}

#[tokio::test]
async fn test_cross_tenant_insert_blocked() {
    let pool = setup_test_db().await;

    let org_a = create_test_org(&pool, "Org A").await;
    let org_b = create_test_org(&pool, "Org B").await;

    // Set context as Org A, but try to insert with Org B's ID
    let mut tx = pool.begin().await.unwrap();
    sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
        .bind(org_a.to_string())
        .execute(&mut *tx)
        .await
        .unwrap();

    // This should be rejected by the WITH CHECK policy
    let result = sqlx::query!(
        "INSERT INTO farms (org_id, name) VALUES ($1, $2)",
        org_b.as_uuid(),  // Wrong org!
        "Sneaky Farm"
    )
    .execute(&mut *tx)
    .await;

    assert!(result.is_err()); // RLS blocks it
}
Enter fullscreen mode Exit fullscreen mode

These tests are non-negotiable. Run them in CI. They're the safety net that catches regressions before your farmers do.

Production Lessons from SmartFarmAI

After running this architecture in production with farms across Nigeria and Tanzania, here's what I've learned:

1. RLS has near-zero performance overhead. PostgreSQL evaluates the policy as part of query planning. For simple equality checks like org_id = current_setting(...)::uuid, the optimiser handles it efficiently. I haven't needed to do anything special for performance.

2. Always index org_id. Even though RLS handles filtering, the database still needs to efficiently find matching rows. Composite indexes on (org_id, ...) are essential:

CREATE INDEX idx_farms_org ON farms(org_id);
CREATE INDEX idx_batches_org_farm ON batches(org_id, farm_id);
CREATE INDEX idx_egg_logs_batch_date ON egg_production_logs(batch_id, record_date DESC);
Enter fullscreen mode Exit fullscreen mode

3. Migrations need care. When you run migrations, you're typically using a superuser or the table owner which bypasses RLS. This is fine and expected, but be aware of it. Don't write migration scripts that assume RLS is filtering data.

4. Debug with current_setting(). When something seems off, check what the database thinks the current tenant is:

SELECT current_setting('app.current_tenant_id', true);
-- Returns NULL if not set, the tenant UUID if set
Enter fullscreen mode Exit fullscreen mode

The second argument (true) tells PostgreSQL to return NULL instead of throwing an error if the variable isn't set. Extremely useful for debugging.

5. The outbox worker issue is real. As I mentioned earlier if you enable RLS on infrastructure tables, your background workers will silently return zero results. No errors, no warnings. Set up monitoring for your outbox queue depth so you catch this fast.

Wrapping Up

Multi-tenant data isolation isn't the kind of thing you want to get "mostly right." It needs to be airtight. PostgreSQL's Row-Level Security, combined with Rust's type system and Actix-web's middleware patterns, gives you defense in depth:

  • Database layer: RLS policies enforce isolation regardless of application bugs.

  • Application layer: Transaction-scoped SET LOCAL prevents connection pool contamination.

SmartFarmAI currently manages farms ranging from 550 birds to 60,000 birds. Every single one of them shares the same database, the same tables, the same application code. And none of them can see each other's data.

If you're building multi-tenant SaaS in Rust, start with RLS. Push security as far down the stack as it will go. And let Rust's type system catch everything else.


I'm Chinedu Okere I write about building production systems in Rust. If you're working on multi-tenant architecture or SaaS in Rust, I'd love to hear about your approach. Find me on Twitter/X or check out SmartFarmAI.

Top comments (0)