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.
Multi-tenancy enables a single ɳSelf infrastructure to serve multiple isolated tenants (customers, organizations, or business units). Each tenant gets:
Multi-customer applications where each customer is a tenant with isolated data, users, and settings.
acme.yourapp.com
techco.yourapp.com
startup.yourapp.comSingle codebase serving multiple branded instances with custom domains and branding.
customer1.com
customer2.com
partner.yourapp.comMulti-department applications where each department or business unit is a tenant.
finance.corp.com
hr.corp.com
sales.corp.comɳ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 │
└──────────────┘ └──────────────┘ └──────────────┘| Strategy | Isolation | Performance | Best For |
|---|---|---|---|
| RLS (Default) | Good | Excellent | High tenant count (100+) |
| Schema-per-Tenant | Excellent | Good | Compliance (GDPR, HIPAA) |
| Hybrid (Recommended) | Excellent | Excellent | Most SaaS applications |
# 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# 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# 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# 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# 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 tenant (disable access)
nself tenant suspend acme
# Activate tenant (re-enable)
nself tenant activate acme# 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 removednself tenant stats
# Output:
# Tenant Statistics
#
# Total Tenants: 45
# Active: 42
# Suspended: 2
# Deleted: 1
#
# Tenants by Plan:
# free: 20
# pro: 15
# enterprise: 10PostgreSQL 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!-- 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 provides the strongest isolation and is recommended for:
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]# 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ɳSelf supports four methods for identifying tenants (in priority order):
curl https://api.yourapp.com/v1/users \
-H "X-Tenant-ID: 550e8400-e29b-41d4-a716-446655440000"Use case: Internal service-to-service communication
curl https://api.yourapp.com/v1/users \
-H "X-Tenant-Slug: acme"Use case: API clients with known tenant slug
curl https://acme.example.com/v1/usersUse case: White-label deployments
curl https://acme.yourapp.com/v1/usersUse case: SaaS multi-tenant applications
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# 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# 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 Encryptnself 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# 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 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# 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}}# 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 accessnself 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# Promote member to admin
nself tenant member update acme <user-uuid> admin
# Demote admin to member
nself tenant member update acme <user-uuid> membernself tenant member remove acme <user-uuid>-- 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// 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])
}// 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)
}
}# 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# 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 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
}// 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 })
}#!/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"# 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# 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# 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();# 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 headerFor 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';# 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 metadataNow that you understand multi-tenancy in ɳSelf:
ɳ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"