Build SaaS applications with multi-tenant architecture using nself, supporting tenant isolation, custom domains, and scalable data separation strategies.
Multi-tenancy allows a single nself deployment to serve multiple customers (tenants) while keeping their data and configurations completely isolated. This is essential for SaaS applications, B2B platforms, and managed services.
Each tenant gets their own PostgreSQL schema:
# Database structure:
# - tenant_a schema
# - tenant_b schema
# - tenant_c schema
# - shared schema (for tenant metadata)
# Benefits:
# ✓ Strong isolation
# ✓ Easy backup/restore per tenant
# ✓ Cost-effective
# ✓ Good performance
# Configuration in schema.dbml:
Project multi_tenant {
database_type: 'PostgreSQL'
Note: 'Multi-tenant SaaS application'
}
// Shared tenant metadata
Table tenants {
id uuid [pk, default: `gen_random_uuid()`]
name varchar(255) [not null]
slug varchar(50) [unique, not null]
subdomain varchar(50) [unique, not null]
custom_domain varchar(255) [unique]
plan tenant_plan [default: 'starter']
status tenant_status [default: 'active']
created_at timestamp [default: `now()`]
updated_at timestamp [default: `now()`]
}
// Each tenant schema will have:
Table users {
id uuid [pk, default: `gen_random_uuid()`]
email varchar(255) [unique, not null]
name varchar(255) [not null]
role user_role [default: 'member']
created_at timestamp [default: `now()`]
updated_at timestamp [default: `now()`]
}
Each tenant gets a separate database:
# Database structure:
# - tenant_a_db
# - tenant_b_db
# - tenant_c_db
# - shared_db (tenant registry)
# Benefits:
# ✓ Maximum isolation
# ✓ Independent scaling
# ✓ Easier compliance (GDPR, HIPAA)
# ✓ Simple tenant migration
# Drawbacks:
# ✗ Higher resource usage
# ✗ More complex management
# ✗ Database connection limits
All tenants share tables with row-level filtering:
# Single database with tenant_id column
Table users {
id uuid [pk, default: `gen_random_uuid()`]
tenant_id uuid [ref: > tenants.id, not null]
email varchar(255) [not null]
name varchar(255) [not null]
created_at timestamp [default: `now()`]
indexes {
tenant_id
(tenant_id, email) [unique]
}
}
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their tenant's data
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::uuid);
# In .env.local or production .env:
MULTI_TENANT_ENABLED=true
TENANT_STRATEGY=schema # schema, database, or rls
# Domain configuration
TENANT_SUBDOMAIN_ENABLED=true
TENANT_SUBDOMAIN_PATTERN=*.yourdomain.com
# Custom domains
TENANT_CUSTOM_DOMAINS_ENABLED=true
# Database settings
TENANT_DB_PREFIX=tenant_
TENANT_SCHEMA_PREFIX=tenant_
# Default tenant (for shared resources)
DEFAULT_TENANT=system
# How tenants are identified:
# 1. Subdomain-based
# customer1.yourdomain.com -> tenant: customer1
# customer2.yourdomain.com -> tenant: customer2
# 2. Custom domain
# customer.com -> tenant: customer_uuid
# anothercustomer.com -> tenant: another_uuid
# 3. Path-based (less common)
# yourdomain.com/customer1/* -> tenant: customer1
# yourdomain.com/customer2/* -> tenant: customer2
# 4. Header-based (API)
# X-Tenant-ID: customer1
# Authorization: Bearer jwt_token (with tenant claim)
# Hasura configuration for schema-based tenancy
# 1. Environment variables per tenant
HASURA_GRAPHQL_DATABASE_URL=postgres://user:pass@host:5432/db?search_path=tenant_{tenant_id},public
# 2. JWT token with tenant claim
{
"sub": "user-uuid",
"tenant_id": "customer1",
"role": "user",
"https://hasura.io/jwt/claims": {
"x-hasura-default-role": "user",
"x-hasura-allowed-roles": ["user", "admin"],
"x-hasura-user-id": "user-uuid",
"x-hasura-tenant-id": "customer1"
}
}
# 3. Hasura permissions with tenant filtering
# Users table permissions:
# - Select: {"tenant_id": {"_eq": "X-Hasura-Tenant-Id"}}
# - Insert: {"tenant_id": {"_eq": "X-Hasura-Tenant-Id"}}
# - Update: {"tenant_id": {"_eq": "X-Hasura-Tenant-Id"}}
# - Delete: {"tenant_id": {"_eq": "X-Hasura-Tenant-Id"}}
# For database-per-tenant setup
# Custom Hasura action to switch database context
# Action: switch_tenant_context
type Mutation {
switchTenantContext(tenant_id: String!): SwitchContextResponse
}
# Handler function:
async function switchTenantContext(req, res) {
const { tenant_id } = req.body.input;
const tenantDbUrl = getTenantDatabaseUrl(tenant_id);
// Update Hasura metadata with new database URL
await updateHasuraDatabase(tenant_id, tenantDbUrl);
res.json({
success: true,
tenant_id,
database_switched: true
});
}
# Create new tenant
nself tenant create --name "Customer Inc" --slug "customer-inc"
# This will:
# 1. Create tenant record in shared database
# 2. Create tenant-specific schema/database
# 3. Apply migrations to tenant schema
# 4. Set up default data
# 5. Configure subdomain/custom domain
# 6. Generate tenant-specific secrets
# Manual tenant creation:
INSERT INTO tenants (name, slug, subdomain, plan, status)
VALUES ('Customer Inc', 'customer-inc', 'customer-inc', 'starter', 'active');
-- Create tenant schema
CREATE SCHEMA tenant_customer_inc;
-- Apply migrations to tenant schema
SET search_path TO tenant_customer_inc, public;
-- ... run migration SQL ...
# Tenant-specific settings
Table tenant_settings {
tenant_id uuid [ref: > tenants.id, pk]
key varchar(100) [not null]
value jsonb
created_at timestamp [default: `now()`]
updated_at timestamp [default: `now()`]
indexes {
(tenant_id, key) [unique]
}
}
# Examples:
# - Theme colors and branding
# - Feature flags per tenant
# - API rate limits
# - Storage quotas
# - Integration settings
// JWT payload structure
{
"sub": "user-123",
"email": "user@customer.com",
"tenant_id": "customer-inc",
"tenant_role": "admin", // Role within the tenant
"https://hasura.io/jwt/claims": {
"x-hasura-default-role": "tenant_admin",
"x-hasura-allowed-roles": ["tenant_admin", "tenant_user"],
"x-hasura-user-id": "user-123",
"x-hasura-tenant-id": "customer-inc"
}
}
// Sign JWT with tenant-specific secret (optional)
const tenantSecret = getTenantJWTSecret(tenant_id);
const token = jwt.sign(payload, tenantSecret);
Table tenant_users {
id uuid [pk, default: `gen_random_uuid()`]
tenant_id uuid [ref: > tenants.id, not null]
user_id uuid [not null] // Global user ID
role tenant_user_role [default: 'member']
status user_status [default: 'active']
invited_by uuid [ref: > tenant_users.id]
joined_at timestamp
created_at timestamp [default: `now()`]
indexes {
(tenant_id, user_id) [unique]
tenant_id
}
}
Enum tenant_user_role {
owner
admin
manager
member
viewer
}
-- Schema-based isolation policies
CREATE POLICY tenant_schema_isolation ON users
USING (current_schema() = 'tenant_' || current_setting('app.current_tenant'));
-- RLS policies for shared tables
CREATE POLICY tenant_data_isolation ON shared_table
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Prevent cross-tenant data leaks
CREATE OR REPLACE FUNCTION ensure_tenant_isolation()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.tenant_id != current_setting('app.current_tenant')::uuid THEN
RAISE EXCEPTION 'Cross-tenant data access violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
// Middleware to enforce tenant isolation
async function tenantIsolationMiddleware(req, res, next) {
const tenantId = getTenantFromRequest(req);
const userTenantId = getUserTenantId(req.user);
if (tenantId !== userTenantId) {
return res.status(403).json({
error: 'Tenant access violation',
code: 'TENANT_FORBIDDEN'
});
}
// Set tenant context for database queries
req.tenantId = tenantId;
next();
}
# Update all tenant schemas
nself tenant migrate --all
# Update specific tenant
nself tenant migrate --tenant customer-inc
# Migration script for all tenants:
#!/bin/bash
for tenant in $(nself tenant list --format json | jq -r '.[].slug'); do
echo "Migrating tenant: $tenant"
nself tenant migrate --tenant "$tenant"
done
# Sometimes tenants need custom schema changes
# hasura/migrations/tenant-specific/customer-inc/
# └── 20240101000000_custom_feature/
# ├── up.sql
# └── down.sql
# Apply tenant-specific migration
nself tenant migrate --tenant customer-inc --specific
# Read replicas per tenant
TENANT_READ_REPLICAS_ENABLED=true
TENANT_READ_REPLICA_customer_inc=replica1.db.com
# Connection pooling per tenant
TENANT_CONNECTION_POOLS=true
TENANT_POOL_SIZE=10
# Tenant database sharding
TENANT_SHARDING_ENABLED=true
TENANT_SHARD_customer_inc=shard-1
TENANT_SHARD_enterprise_corp=shard-2
# Tenant-aware load balancing
# Route requests to tenant-specific application instances
# Based on subdomain or tenant ID
# Large tenants get dedicated resources
TENANT_DEDICATED_INSTANCES=enterprise-corp,big-customer
# Smaller tenants share resources
TENANT_SHARED_INSTANCES=starter,basic,pro
# Track metrics per tenant
# - API request volume
# - Database queries
# - Storage usage
# - Active users
# - Error rates
# - Performance metrics
# Grafana dashboard queries by tenant
SELECT tenant_id, COUNT(*) as requests
FROM api_logs
WHERE timestamp > now() - interval '1 hour'
GROUP BY tenant_id;
# Monitor tenant health
nself tenant health --all
# Check specific tenant
nself tenant health --tenant customer-inc
# Automated health checks per tenant
crontab -e
*/5 * * * * /usr/local/bin/nself tenant health --all --alert
# Backup all tenants
nself tenant backup --all
# Backup specific tenant
nself tenant backup --tenant customer-inc
# Restore tenant data
nself tenant restore --tenant customer-inc --from backup-20240101.sql
# Automated daily backups per tenant
#!/bin/bash
for tenant in $(nself tenant list --active); do
nself tenant backup --tenant "$tenant" --name "daily-$(date +%Y%m%d)"
done
# Data export for GDPR requests
nself tenant export --tenant customer-inc --user user@example.com
# Data deletion (right to be forgotten)
nself tenant delete-user-data --tenant customer-inc --user user@example.com
# Audit trail for compliance
Table audit_log {
id uuid [pk, default: `gen_random_uuid()`]
tenant_id uuid [ref: > tenants.id, not null]
user_id uuid
action varchar(100) [not null]
resource_type varchar(100)
resource_id varchar(100)
changes jsonb
ip_address inet
user_agent text
timestamp timestamp [default: `now()`]
indexes {
tenant_id
user_id
timestamp
}
}