ɳSelfɳSELFDOCS
  • Getting Started

    • Introduction
    • Quick Start
    • Installation
    • Your First Project
  • Core Concepts

    • Architecture Overview
    • Project Structure
    • Configuration
    • Environments
  • Services

    • PostgreSQL Database
    • Hasura GraphQL
    • Authentication
    • Real-Time Communication
    • Storage (MinIO)
    • Email Configuration
    • Redis Cache
    • Search Engines
    • Functions
    • MLflow (ML Tracking)
    • Monitoring & Metrics
    • Admin UI
    • Dashboard
  • Database Tools

    • Schema Management
    • Migrations
    • Seeding Data
    • Backup & Restore
    • dbdiagram.io Sync
  • Microservices

    • NestJS Services
    • BullMQ Workers
    • Go Services
    • Python Services
  • CLI Reference

    • Complete Command Reference
    • Core Commands
    • Database Commands
    • Service Management
    • Production Commands
  • Deployment

    • Local Development
    • Production Setup
    • SSL/TLS Configuration
    • Domain Configuration
    • Environment Variables
  • Advanced Topics

    • Multi-Tenancy & SaaS
    • Security & Hardening
    • Custom Actions
    • Webhooks
    • Performance Tuning
    • Troubleshooting
  • Migration Guides

    • From Supabase
    • From Nhost
    • From Firebase
  • Resources

    • Changelog
    • Licensing
    • FAQ
    • Contributing
    • Support

Multi-Tenancy System

v0.9.0 - MAJOR FEATUREProduction-Ready Multi-Tenancy

Build enterprise-grade SaaS applications with ɳSelf's comprehensive multi-tenancy system. Support multiple isolated tenants with row-level security, custom domains, white-label branding, and usage-based billing—all integrated out of the box.

v0.9.0: Complete Multi-Tenancy Stack

  • • Tenant Management: Full CLI commands for tenant operations
  • • Data Isolation: PostgreSQL RLS + schema-per-tenant support
  • • White-Label Branding: Custom logos, themes, and domains
  • • Billing Integration: Usage tracking, quotas, and Stripe billing
  • • Member Management: Roles, permissions, and invitations

Overview

What is Multi-Tenancy?

Multi-tenancy enables a single ɳSelf infrastructure to serve multiple isolated tenants (customers, organizations, or business units). Each tenant gets:

  • Complete Data Isolation: PostgreSQL Row-Level Security (RLS) ensures tenant data never crosses boundaries
  • Independent Schemas: Optional dedicated PostgreSQL schemas per tenant for maximum isolation
  • Custom Branding: Per-tenant logos, colors, themes, and white-label settings
  • Resource Quotas: Enforced limits on users, storage, API requests, and compute
  • Custom Domains: Tenant-specific subdomains or fully custom domains with SSL
  • Usage Tracking: Built-in metering for billing and analytics

Use Cases

SaaS Platforms

Multi-customer applications where each customer is a tenant with isolated data, users, and settings.

acme.yourapp.com
techco.yourapp.com
startup.yourapp.com

White-Label Reselling

Single codebase serving multiple branded instances with custom domains and branding.

customer1.com
customer2.com
partner.yourapp.com

B2B Enterprise

Multi-department applications where each department or business unit is a tenant.

finance.corp.com
hr.corp.com
sales.corp.com

Architecture Approach

ɳSelf uses a hybrid multi-tenancy model that balances security, performance, and flexibility:

┌─────────────────────────────────────────────────────────────┐
│                   Shared Infrastructure                      │
│  ┌──────────┐  ┌─────────┐  ┌────────┐  ┌──────────┐      │
│  │PostgreSQL│  │ Hasura  │  │ Redis  │  │  Nginx   │      │
│  └──────────┘  └─────────┘  └────────┘  └──────────┘      │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│                   Tenant Isolation Layer                     │
│  • Row-Level Security (RLS) - All shared tables             │
│  • Schema-per-tenant - Optional dedicated schemas           │
│  • Redis namespaces - Per-tenant cache isolation            │
│  • MinIO buckets - Per-tenant storage isolation             │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  Tenant A    │  │  Tenant B    │  │  Tenant C    │
│  acme.app.com│  │  tech.app.com│  │  custom.com  │
└──────────────┘  └──────────────┘  └──────────────┘

Isolation Strategies

StrategyIsolationPerformanceBest For
RLS (Default)GoodExcellentHigh tenant count (100+)
Schema-per-TenantExcellentGoodCompliance (GDPR, HIPAA)
Hybrid (Recommended)ExcellentExcellentMost SaaS applications

Quick Start

1. Initialize Multi-Tenancy

# Initialize the multi-tenancy system (run once)
nself tenant init

# This creates:
# - tenants schema and tables
# - RLS policies on all tenant-aware tables
# - Default tenant for existing data
# - Helper functions and triggers

2. Create Your First Tenant

# Basic tenant creation
nself tenant create "Acme Corporation"

# With custom slug and plan
nself tenant create "Acme Corp" --slug acme --plan pro

# With specific owner
nself tenant create "TechCo" \
  --slug techco \
  --owner <user-uuid> \
  --plan enterprise

# Output:
# ✓ Tenant created: acme (ID: 550e8400-e29b-41d4-a716-446655440000)
#   Owner: user-uuid-here
#   Plan: pro
#   URL: https://acme.yourapp.com

3. Configure Tenant Routing

# In .env - Enable multi-tenancy
MULTI_TENANCY_ENABLED=true
TENANT_ROUTING_METHOD=subdomain  # subdomain | custom_domain | both

# Subdomain routing (most common)
BASE_DOMAIN=yourapp.com
TENANT_SUBDOMAIN_WILDCARD=true

# Custom domain support
TENANT_CUSTOM_DOMAINS_ENABLED=true
TENANT_REQUIRE_DOMAIN_VERIFICATION=true

Tenant Management Commands

List Tenants

# Table format
nself tenant list

# Output:
# ID                                   SLUG    NAME              STATUS    PLAN
# ──────────────────────────────────────────────────────────────────────────────
# 550e8400-e29b-41d4-a716-446655440000 acme    Acme Corporation  active    pro
# 660f9511-f3ac-52e5-b827-557766551111 techco  TechCo Inc        active    enterprise
# 770c0622-g4bd-63f6-c938-668877662222 startup Startup LLC       suspended free

# JSON format (for scripting)
nself tenant list --json

Show Tenant Details

# By slug or ID
nself tenant show acme

# Output:
# Tenant Details:
#   ID: 550e8400-e29b-41d4-a716-446655440000
#   Slug: acme
#   Name: Acme Corporation
#   Status: active
#   Plan: pro
#   Owner: user-uuid-here
#   Members: 15
#   Max Users: 50
#   Max Storage: 100 GB
#   Max API Requests: 100,000/month
#   Created: 2025-01-15 10:30:00

Suspend/Activate Tenant

# Suspend tenant (disable access)
nself tenant suspend acme

# Activate tenant (re-enable)
nself tenant activate acme

Delete Tenant

# Soft delete with confirmation
nself tenant delete acme

# What happens:
# 1. Tenant schema dropped (if using schema-per-tenant)
# 2. Tenant status set to 'deleted'
# 3. All related data cascade-deleted
# 4. Custom domains removed
# 5. User memberships removed

Tenant Statistics

nself tenant stats

# Output:
# Tenant Statistics
#
# Total Tenants: 45
# Active: 42
# Suspended: 2
# Deleted: 1
#
# Tenants by Plan:
#   free: 20
#   pro: 15
#   enterprise: 10

Row-Level Security (RLS)

How RLS Works

PostgreSQL Row-Level Security ensures that users can only access data within their tenant, even if they craft malicious queries:

-- Every table has a tenant_id column
CREATE TABLE users (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  email TEXT,
  name TEXT
);

-- RLS policy filters all queries automatically
CREATE POLICY user_isolation ON users
  FOR ALL
  USING (tenant_id = tenants.current_tenant_id());

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

-- Now all queries are automatically filtered:
SELECT * FROM users;
-- Becomes: SELECT * FROM users WHERE tenant_id = current_tenant_id();

-- Even malicious queries are filtered:
SELECT * FROM users WHERE 1=1 OR tenant_id != current_tenant_id();
-- Still filtered to current tenant only!

Tenant Context Functions

-- Get current tenant ID from session
CREATE OR REPLACE FUNCTION tenants.current_tenant_id()
RETURNS UUID AS $$
BEGIN
    RETURN current_setting('hasura.user.x-hasura-tenant-id', true)::uuid;
EXCEPTION
    WHEN OTHERS THEN
        RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;

-- Get current user ID
CREATE OR REPLACE FUNCTION tenants.current_user_id()
RETURNS UUID AS $$
BEGIN
    RETURN current_setting('hasura.user.x-hasura-user-id', true)::uuid;
EXCEPTION
    WHEN OTHERS THEN
        RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;

-- Check if user is member of tenant
CREATE OR REPLACE FUNCTION tenants.is_tenant_member(
  p_tenant_id UUID,
  p_user_id UUID
)
RETURNS BOOLEAN AS $$
BEGIN
    RETURN EXISTS (
        SELECT 1 FROM tenants.tenant_members
        WHERE tenant_id = p_tenant_id
        AND user_id = p_user_id
    );
END;
$$ LANGUAGE plpgsql STABLE;

Schema-per-Tenant

When to Use

Schema-per-tenant provides the strongest isolation and is recommended for:

  • Compliance Requirements: GDPR, HIPAA, or other regulations requiring strict data separation
  • Large Tenants: Enterprise customers with millions of records
  • Custom Schema Needs: Tenants requiring schema customization
  • Easy Backup/Restore: Per-tenant data export and migration

Database Structure

PostgreSQL Database: myapp_db
│
├── tenants schema (Tenant Management)
│   ├── tenants                    -- Tenant registry
│   ├── tenant_schemas             -- Schema tracking
│   ├── tenant_domains             -- Custom domains
│   ├── tenant_members             -- User-tenant membership
│   └── tenant_settings            -- Per-tenant settings
│
├── auth schema (Authentication - RLS Enabled)
│   ├── users (tenant_id)          -- Tenant-isolated users
│   ├── sessions (tenant_id)       -- Tenant-isolated sessions
│   ├── refresh_tokens (tenant_id) -- Tenant-isolated tokens
│   └── api_keys (tenant_id)       -- Tenant-isolated API keys
│
└── tenant_550e8400 schema (Tenant Data)
    ├── products
    ├── orders
    ├── invoices
    └── [Custom tenant tables]

Enable Schema-per-Tenant

# In .env
TENANT_STRATEGY=schema
TENANT_SCHEMA_PREFIX=tenant_

# Create tenant with dedicated schema
nself tenant create "Acme Corp" --slug acme --strategy schema

# This will:
# 1. Create tenant record in tenants.tenants
# 2. Create dedicated schema: tenant_550e8400
# 3. Apply all migrations to tenant schema
# 4. Set up RLS policies

Tenant Routing

Tenant Identification Priority

ɳSelf supports four methods for identifying tenants (in priority order):

1

X-Tenant-ID Header (Direct)

curl https://api.yourapp.com/v1/users \
  -H "X-Tenant-ID: 550e8400-e29b-41d4-a716-446655440000"

Use case: Internal service-to-service communication

2

X-Tenant-Slug Header

curl https://api.yourapp.com/v1/users \
  -H "X-Tenant-Slug: acme"

Use case: API clients with known tenant slug

3

Custom Domain

curl https://acme.example.com/v1/users

Use case: White-label deployments

4

Subdomain (Most Common)

curl https://acme.yourapp.com/v1/users

Use case: SaaS multi-tenant applications

Subdomain Routing Flow

Request: https://acme.yourapp.com/api/users
         ↓
Nginx extracts subdomain: "acme"
         ↓
Lua script queries database:
  SELECT id FROM tenants.tenants WHERE slug = 'acme'
         ↓
Tenant ID added to headers:
  X-Hasura-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000
  X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000
         ↓
Proxied to Hasura with tenant context
         ↓
PostgreSQL RLS enforces isolation

Custom Domains with SSL

Add Custom Domain

# Add domain to tenant
nself tenant domain add acme acme.example.com

# Output:
# ✓ Domain added: acme.example.com
#   Verification token: a3f5c9e7d2b4a6f8e9c7d5b3a1f4c6e8
#   Add this TXT record to your DNS:
#     nself-verify=a3f5c9e7d2b4a6f8e9c7d5b3a1f4c6e8

Verify Domain

# After adding DNS TXT record
nself tenant domain verify acme acme.example.com

# Output:
# ✓ Domain verified: acme.example.com
#   SSL certificate will be generated automatically via Let's Encrypt

List Domains

nself tenant domain list acme

# Output:
# DOMAIN              PRIMARY  VERIFIED  VERIFIED_AT           CREATED_AT
# ────────────────────────────────────────────────────────────────────────
# acme.example.com    true     true      2025-01-20 15:30:00   2025-01-20
# api.acme.com        false    true      2025-01-21 09:00:00   2025-01-21

White-Label Branding

Set Tenant Branding

# Set brand colors
ɳSelf whitelabel brand set acme --primary-color "#FF6B35"
ɳSelf whitelabel brand set acme --secondary-color "#004E89"
ɳSelf whitelabel brand set acme --accent-color "#00CC66"

# Upload logo
ɳSelf whitelabel logo upload acme logo.svg --type main

# Set company info
nself tenant setting set acme branding.company_name "Acme Corporation"
nself tenant setting set acme branding.tagline "Innovation Delivered"
nself tenant setting set acme contact.support_email "support@acme.com"

Create Custom Theme

# Create theme for tenant
ɳSelf whitelabel theme create acme "Acme Light Theme" \
  --primary "#FF6B35" \
  --secondary "#004E89" \
  --background "#FFFFFF" \
  --text "#333333"

# Activate theme
ɳSelf whitelabel theme activate acme acme-light

# List themes
ɳSelf whitelabel theme list acme

Email Template Customization

# Customize welcome email
ɳSelf whitelabel email customize acme welcome \
  --subject "Welcome to {{TENANT_NAME}}!" \
  --template-file ./templates/welcome.html

# Available variables:
# {{TENANT_NAME}}
# {{USER_NAME}}
# {{USER_EMAIL}}
# {{LOGO_URL}}
# {{PRIMARY_COLOR}}
# {{SUPPORT_URL}}

Member Management & Roles

Add Members to Tenant

# Add as member (default role)
nself tenant member add acme <user-uuid>

# Add with specific role
nself tenant member add acme <user-uuid> admin

# Available roles:
# - owner:  Full control (billing, deletion)
# - admin:  Management (users, settings)
# - member: Standard access
# - guest:  Read-only access

List Members

nself tenant member list acme

# Output:
# USER_ID                              ROLE    JOINED_AT
# ──────────────────────────────────────────────────────
# user-uuid-1                          owner   2025-01-15
# user-uuid-2                          admin   2025-01-16
# user-uuid-3                          member  2025-01-20

Update Member Role

# Promote member to admin
nself tenant member update acme <user-uuid> admin

# Demote admin to member
nself tenant member update acme <user-uuid> member

Remove Member

nself tenant member remove acme <user-uuid>

Usage Tracking & Quotas

Define Quotas per Plan

-- Define plan quotas in database
CREATE TABLE billing_plans (
  id UUID PRIMARY KEY,
  name VARCHAR(50) UNIQUE NOT NULL,
  max_users INTEGER DEFAULT 5,
  max_storage_gb INTEGER DEFAULT 1,
  max_api_requests_per_month INTEGER DEFAULT 10000,
  max_compute_hours INTEGER DEFAULT 10,
  price_monthly DECIMAL(10,2)
);

-- Insert plans
INSERT INTO billing_plans (name, max_users, max_storage_gb, max_api_requests_per_month, price_monthly) VALUES
('free', 5, 1, 10000, 0.00),
('starter', 10, 10, 50000, 29.00),
('pro', 50, 100, 100000, 99.00),
('enterprise', -1, -1, -1, 499.00); -- -1 = unlimited

Track Usage Events

// Record API request usage
async function trackAPIRequest(tenantId: string) {
  await db.query(`
    INSERT INTO billing_usage_records (
      tenant_id,
      service_name,
      quantity,
      recorded_at
    ) VALUES ($1, 'api_requests', 1, NOW())
  `, [tenantId])
}

// Track storage usage (GB-hours)
async function trackStorageUsage(tenantId: string, sizeGB: number) {
  await db.query(`
    INSERT INTO billing_usage_records (
      tenant_id,
      service_name,
      quantity,
      metadata,
      recorded_at
    ) VALUES ($1, 'storage', $2, '{"unit": "gb_hour"}', NOW())
  `, [tenantId, sizeGB])
}

Check Quota Before Request

// Middleware to enforce quotas
async function checkQuotaMiddleware(req, res, next) {
  const tenantId = req.headers['x-tenant-id']

  // Check API request quota
  const quota = await checkAPIQuota(tenantId)

  if (quota.exceeded) {
    return res.status(429).json({
      error: 'API quota exceeded',
      limit: quota.limit,
      current: quota.current,
      resetDate: quota.resetDate
    })
  }

  // Record usage
  await trackAPIRequest(tenantId)

  next()
}

async function checkAPIQuota(tenantId: string) {
  const result = await db.query(`
    SELECT
      t.max_api_requests_per_month as limit,
      COUNT(u.id) as current
    FROM tenants.tenants t
    LEFT JOIN billing_usage_records u
      ON t.id = u.tenant_id
      AND u.service_name = 'api_requests'
      AND u.recorded_at > date_trunc('month', NOW())
    WHERE t.id = $1
    GROUP BY t.id
  `, [tenantId])

  const { limit, current } = result[0]

  return {
    limit,
    current,
    exceeded: current >= limit,
    resetDate: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1)
  }
}

View Tenant Usage

# Get current month usage
nself tenant usage acme

# Output:
# Tenant Usage: acme (January 2026)
#
# API Requests: 45,230 / 100,000 (45%)
# Storage: 42 GB / 100 GB (42%)
# Compute Hours: 18.5 / Unlimited
# Active Users: 23 / 50 (46%)
#
# Estimated Charges: $99.00/month

Billing Integration

Connect Stripe

# Configure Stripe in .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Enable billing
BILLING_ENABLED=true
BILLING_PROVIDER=stripe

Create Subscription

// Create Stripe customer and subscription
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

async function createTenantSubscription(
  tenantId: string,
  planName: string,
  paymentMethodId: string
) {
  const tenant = await getTenant(tenantId)

  // Create or get Stripe customer
  let customerId = tenant.stripe_customer_id

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: tenant.owner_email,
      name: tenant.name,
      metadata: { tenant_id: tenantId }
    })

    customerId = customer.id

    await db.query(`
      UPDATE tenants.tenants
      SET stripe_customer_id = $1
      WHERE id = $2
    `, [customerId, tenantId])
  }

  // Attach payment method
  await stripe.paymentMethods.attach(paymentMethodId, {
    customer: customerId
  })

  // Set as default payment method
  await stripe.customers.update(customerId, {
    invoice_settings: {
      default_payment_method: paymentMethodId
    }
  })

  // Create subscription
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: getPriceId(planName) }],
    metadata: { tenant_id: tenantId }
  })

  // Update tenant with subscription info
  await db.query(`
    UPDATE tenants.tenants
    SET
      stripe_subscription_id = $1,
      plan_id = $2,
      subscription_status = $3
    WHERE id = $4
  `, [subscription.id, planName, subscription.status, tenantId])

  return subscription
}

Handle Webhooks

// Stripe webhook handler
export async function handleStripeWebhook(req, res) {
  const sig = req.headers['stripe-signature']

  let event
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    )
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`)
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await updateTenantSubscription(event.data.object)
      break

    case 'customer.subscription.deleted':
      await suspendTenant(event.data.object.metadata.tenant_id)
      break

    case 'invoice.paid':
      await recordPayment(event.data.object)
      break

    case 'invoice.payment_failed':
      await handlePaymentFailure(event.data.object)
      break
  }

  res.json({ received: true })
}

Production Best Practices

Security

  • Always use RLS: Never rely solely on application-level filtering
  • Test isolation thoroughly: Verify tenants cannot access each other's data
  • Audit RLS policies: Review all policies for potential leaks
  • Log tenant context: Include tenant_id in all application logs
  • Validate tenant context: Always verify tenant_id matches user's tenant

Performance

  • Index tenant_id columns: Add indexes on all tenant_id foreign keys
  • Use composite indexes: (tenant_id, other_columns) for common queries
  • Cache tenant data: Use Redis to cache tenant metadata
  • Monitor per-tenant metrics: Track query performance by tenant
  • Consider read replicas: For large tenants with heavy read traffic

Scalability

  • Connection pooling: Use PgBouncer for efficient connection management
  • Tenant sharding: Move large tenants to dedicated databases
  • Async usage tracking: Use queues for non-blocking usage recording
  • Quota enforcement: Implement both soft limits (warnings) and hard limits (blocks)
  • Monitoring: Set up alerts for quota thresholds and tenant health

Testing Multi-Tenancy

Test Isolation

#!/bin/bash
# test-tenant-isolation.sh

echo "Testing tenant data isolation..."

TENANT_A=$(psql -t -c "SELECT id FROM tenants.tenants WHERE slug='tenant-a'" | tr -d ' ')
TENANT_B=$(psql -t -c "SELECT id FROM tenants.tenants WHERE slug='tenant-b'" | tr -d ' ')

# Test 1: Tenant A cannot see Tenant B users
COUNT=$(psql -t -c "
  SET hasura.user.x-hasura-tenant-id = '$TENANT_A';
  SELECT COUNT(*) FROM auth.users WHERE tenant_id = '$TENANT_B';
" | tr -d ' ')

if [ "$COUNT" -eq "0" ]; then
  echo "✓ PASS: Tenant A cannot see Tenant B users"
else
  echo "✗ FAIL: Tenant isolation breach!"
  exit 1
fi

# Test 2: Tenant A can see own users
COUNT=$(psql -t -c "
  SET hasura.user.x-hasura-tenant-id = '$TENANT_A';
  SELECT COUNT(*) FROM auth.users WHERE tenant_id = '$TENANT_A';
" | tr -d ' ')

if [ "$COUNT" -gt "0" ]; then
  echo "✓ PASS: Tenant A can see own users"
else
  echo "✗ FAIL: Tenant cannot see own users"
  exit 1
fi

echo "✓ All tenant isolation tests passed"

Migration Guide

Converting Single-Tenant to Multi-Tenant

# Step 1: Backup existing data
nself db backup --output pre-migration-backup.sql

# Step 2: Initialize multi-tenancy
nself tenant init

# Step 3: Create default tenant
nself tenant create "Default" --slug default

# Step 4: Migrate existing data
DEFAULT_TENANT_ID=$(psql -t -c "SELECT id FROM tenants.tenants WHERE slug='default'" | tr -d ' ')

psql <<EOF
-- Add tenant_id to existing tables
ALTER TABLE users ADD COLUMN tenant_id UUID;
ALTER TABLE products ADD COLUMN tenant_id UUID;
ALTER TABLE orders ADD COLUMN tenant_id UUID;

-- Backfill with default tenant
UPDATE users SET tenant_id = '$DEFAULT_TENANT_ID' WHERE tenant_id IS NULL;
UPDATE products SET tenant_id = '$DEFAULT_TENANT_ID' WHERE tenant_id IS NULL;
UPDATE orders SET tenant_id = '$DEFAULT_TENANT_ID' WHERE tenant_id IS NULL;

-- Make NOT NULL after backfill
ALTER TABLE users ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE products ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE orders ALTER COLUMN tenant_id SET NOT NULL;

-- Add indexes
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_products_tenant ON products(tenant_id);
CREATE INDEX idx_orders_tenant ON orders(tenant_id);

-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
EOF

# Step 5: Test isolation
./test-tenant-isolation.sh

Troubleshooting

Common Issues

Issue: RLS Policy Not Working

# Check if RLS is enabled
SELECT tablename, rowsecurity
FROM pg_tables
WHERE tablename = 'users';

# Check policies
SELECT * FROM pg_policies WHERE tablename = 'users';

# Test policy manually
SET hasura.user.x-hasura-tenant-id = 'tenant-a-uuid';
SELECT * FROM users WHERE tenant_id = 'tenant-b-uuid';
-- Should return 0 rows

Issue: Slow Queries

# Add index on tenant_id
CREATE INDEX CONCURRENTLY idx_users_tenant ON users(tenant_id);

# Use EXPLAIN ANALYZE to debug
EXPLAIN ANALYZE SELECT * FROM users WHERE tenant_id = current_tenant_id();

Issue: Tenant Context Not Set

# Check session variable
SHOW hasura.user.x-hasura-tenant-id;

# Verify JWT includes tenant_id claim
# Check Hasura JWT configuration
# Ensure nginx passes X-Tenant-ID header

Advanced Topics

Multi-Organization Tenancy

For enterprise customers with multiple organizations, you can map multiple tenants to a single organization:

CREATE TABLE organizations.org_tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL REFERENCES organizations.organizations(id),
    tenant_id UUID NOT NULL REFERENCES tenants.tenants(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (org_id, tenant_id)
);

-- Get all tenants for an organization
SELECT t.*
FROM tenants.tenants t
JOIN organizations.org_tenants ot ON t.id = ot.tenant_id
WHERE ot.org_id = 'org-uuid';

Tenant Data Export (GDPR)

# Export all tenant data
nself tenant export acme --output acme-data-export.tar.gz

# Includes:
# - PostgreSQL dump (tenant schema)
# - MinIO objects (tenant bucket)
# - Audit logs (tenant-specific)
# - Settings and metadata

Next Steps

Now that you understand multi-tenancy in ɳSelf:

  • Authentication - Configure JWT with tenant claims
  • Domain Configuration - Set up custom domains
  • Production Setup - Deploy multi-tenant infrastructure
  • Monitoring - Track per-tenant metrics and usage
  • Security - Harden multi-tenant security

Ready to Build Multi-Tenant SaaS?

ɳSelf's multi-tenancy system provides everything you need to build production-ready SaaS applications with enterprise-grade isolation, white-label branding, and usage-based billing.

# Get started now
nself tenant init
nself tenant create "Your First Tenant"