Multi-Tenancy
Fluxbase uses a database-per-tenant architecture for complete data isolation. Each tenant gets its own PostgreSQL database, while shared services (authentication, storage, functions, etc.) are accessed via postgres_fdw. RLS enforces tenant boundaries at the database level.
Architecture
Section titled “Architecture”graph TD subgraph "Main Database" A[auth.* tables] B[storage.* tables] C[functions.* tables] D[jobs.* tables] E[platform.tenants] F[platform.service_keys] end
subgraph "Tenant A Database" G[public.* - Tenant A data only] H[FDW: auth, storage, functions, jobs...] end
subgraph "Tenant B Database" I[public.* - Tenant B data only] J[FDW: auth, storage, functions, jobs...] end
subgraph "Default Tenant" K[public.* - shared main database] end
E --> G E --> I E --> K
G ---|postgres_fdw + RLS| A G ---|postgres_fdw + RLS| B I ---|postgres_fdw + RLS| A I ---|postgres_fdw + RLS| B
style H fill:#f0f0f0,stroke:#999 style J fill:#f0f0f0,stroke:#999How It Works
Section titled “How It Works”- Default tenant: Uses the main database directly. No separate database or FDW setup needed.
- Named tenants: When created, Fluxbase provisions a separate PostgreSQL database (named
{prefix}{slug}, e.g.,tenant_acme-corp). - FDW setup: A per-tenant FDW role (
fdw_tenant_<uuid8>) is created withNOBYPASSRLSandapp.current_tenant_idset. Shared schemas are imported as foreign tables so the tenant database can access auth, storage, functions, etc. - Connection routing: When a request carries tenant context (via
X-FB-Tenantheader or JWT claims), Fluxbase routes to the tenant’s database pool. The pool priority is: branch pool > tenant pool > main pool. - RLS enforcement: All tenant-scoped tables use RLS with
app.current_tenant_idto filter data. The FDW role inherits this setting, ensuring tenant-scoped queries only see the tenant’s data even for shared tables.
Key Types
Section titled “Key Types”Tenant
Section titled “Tenant”A tenant represents an organization or customer in your SaaS application:
interface Tenant { id: string; slug: string; name: string; is_default: boolean; status: string; // "creating" | "active" | "deleting" | "error" db_name?: string | null; // null = uses main database (default tenant) metadata: Record<string, unknown> | null; created_at: string; updated_at?: string; deleted_at: string | null;}The db_name field indicates whether a tenant uses a separate database. When null, the tenant uses the main database (like the default tenant). When set, it’s the name of the tenant’s dedicated PostgreSQL database.
Service Key Types
Section titled “Service Key Types”Fluxbase supports multiple key types for different use cases:
| Key Type | Prefix | Scope | Use Case |
|---|---|---|---|
anon | pk_anon_ | Tenant | Anonymous/public access |
publishable | pk_live_ | Tenant | Client-side API access |
tenant_service | sk_tenant_ | Tenant | Backend services, scoped to one tenant |
global_service | sk_global_ | Instance | Backend services, bypasses RLS, all tenants |
service | sk_ | Instance | Legacy service key, bypasses RLS |
When creating keys via the API, use key_type: "anon" for public access or key_type: "service" for backend service access.
Tenant Service Keys
Section titled “Tenant Service Keys”Tenant service keys are scoped to a specific tenant and automatically enforce tenant isolation:
import { createClient } from "@nimbleflux/fluxbase-sdk";
// Tenant-scoped client - all operations are isolated to tenantconst tenantClient = createClient( "http://localhost:8080", "tenant-service-key-here",);
// This query only returns data for the key's tenantconst users = await tenantClient.from("users").select("*");Creating Tenant Service Keys
Section titled “Creating Tenant Service Keys”Use the Admin SDK to create tenant-scoped keys:
// Create a tenant service keyconst { data: key, error } = await client.admin.serviceKeys.create({ name: "Production API Key", key_type: "service", scopes: ["*"],});Key Rotation
Section titled “Key Rotation”Service keys support graceful rotation:
// Deprecate old key with grace periodawait client.admin.serviceKeys.deprecate("old-key-id", { grace_period_hours: 24,});
// During grace period, both keys work// After grace period, old key is revokedPlatform Admin Roles
Section titled “Platform Admin Roles”Fluxbase uses a two-tier admin system:
Instance Admin (instance_admin)
Section titled “Instance Admin (instance_admin)”- Full access to all tenants and data
- Can create/delete tenants
- Can manage global service keys
- Can assign tenant admins
- Bypasses RLS (has
BYPASSRLSPostgreSQL attribute)
Tenant Admin (tenant_admin)
Section titled “Tenant Admin (tenant_admin)”- Limited to their assigned tenants
- Can manage tenant service keys
- Can manage users within their tenant
- Cannot access other tenants
- Respects RLS (maps to
authenticatedrole for user data)
Role Assignment
Section titled “Role Assignment”-- Check if user is instance adminSELECT platform.is_instance_admin('user-uuid');
-- Get tenant IDs managed by a user (returns uuid[])SELECT unnest(platform.user_managed_tenant_ids('user-uuid'));
-- Assign tenant adminINSERT INTO platform.tenant_admin_assignments (user_id, tenant_id, assigned_by)VALUES ('user-uuid', 'tenant-uuid', 'admin-uuid');Configuration
Section titled “Configuration”Default Tenant
Section titled “Default Tenant”Configure a default tenant with pre-generated keys:
tenants: default: name: "Default Tenant" # Option 1: Direct key values anon_key: "your-anon-key" service_key: "your-service-key"
# Option 2: Load from files (recommended for production) anon_key_file: "/secrets/anon-key" service_key_file: "/secrets/service-key" ```
All settings also have `FLUXBASE_*` environment variable equivalents (e.g., `FLUXBASE_TENANTS_DEFAULT_NAME`, `FLUXBASE_TENANTS_DEFAULT_ANON_KEY`).
### Tenant Infrastructure
```yamltenants: enabled: true database_prefix: "tenant_" # Tenant DBs are named tenant_acme-corp max_tenants: 100
pool: max_total_connections: 100 # Across all tenant pools eviction_age: 30m # LRU pool eviction threshold
migrations: check_interval: 5m # Background migration worker interval on_create: true # Run system migrations on tenant creation on_access: true # Lazy migrations on first pool access background: true # Enable background migration workerCreating Tenants
Section titled “Creating Tenants”Via API
Section titled “Via API”When creating a tenant, Fluxbase can automatically provision a separate database:
import { createClient } from "@nimbleflux/fluxbase-sdk";
const client = createClient("http://localhost:8080", "global-service-key");
// Create a tenant with an auto-provisioned databaseconst { data: tenant, error } = await client.tenant.create({ slug: "acme-corp", name: "Acme Corporation", metadata: { plan: "enterprise", billing_email: "billing@acme.com", },});Full Creation Options
Section titled “Full Creation Options”const { data: tenant, error } = await client.tenant.create({ slug: "acme-corp", // Required: lowercase, hyphens, starts with letter name: "Acme Corporation", // Required: display name metadata: { plan: "enterprise" },
// Database options db_mode: "auto", // "auto" (new DB) or "existing" (use existing DB) db_name: null, // Required when db_mode="existing"
// Admin assignment admin_email: "admin@acme.com", // Invite by email (creates invitation) admin_user_id: "user-uuid", // Or assign existing user directly
// Key generation auto_generate_keys: true, // Auto-create anon + service keys (default: true) send_keys_to_email: true, // Include keys in invitation email});Tenant Lifecycle
Section titled “Tenant Lifecycle”The tenant creation flow:
- A record is inserted in
platform.tenantswith statuscreating - A new PostgreSQL database is created (e.g.,
tenant_acme-corp) - Bootstrap runs: schemas, roles, and privileges are set up
- Internal Fluxbase schemas (auth, storage, functions, jobs, etc.) are applied
- FDW is configured: a per-tenant role is created, shared schemas are imported as foreign tables
- Declarative schema is applied (if configured)
- Status is set to
active
Tenant CRUD
Section titled “Tenant CRUD”// List all tenantsconst { data: tenants, error } = await client.tenant.list();
// Update tenantawait client.tenant.update(tenant.id, { name: "Acme Corp Inc.", metadata: { plan: "pro" },});
// Soft delete tenant (sets deleted_at, keeps data)await client.tenant.delete(tenant.id);
// Hard delete tenant (destroys database and all data)await client.tenant.delete(tenant.id, { hard: true });
// Recover a soft-deleted tenantawait client.tenant.recover(tenant.id);
// Repair tenant (re-runs bootstrap + FDW setup)await client.tenant.repair(tenant.id);
// Migrate tenant to latest schemaawait client.tenant.migrate(tenant.id);Service Key Management
Section titled “Service Key Management”// List keys for current tenant contextconst { data: keys, error } = await client.admin.serviceKeys.list();
// Create tenant service keyconst { data: key, error: keyError } = await client.admin.serviceKeys.create({ name: "Backend Service", key_type: "service", scopes: ["*"],});
// Revoke a keyawait client.admin.serviceKeys.revoke(key.id, { reason: "Security incident" });
// Rotate keysconst { data: newKey, error: rotateError } = await client.admin.serviceKeys.rotate(oldKeyId);Tenant Context in Queries
Section titled “Tenant Context in Queries”When using a tenant service key or sending the X-FB-Tenant header, all queries are automatically scoped:
// With tenant service keyconst tenantClient = createClient("http://localhost:8080", "tenant-key");
// Only returns data for this tenant (enforced by RLS)const users = await tenantClient.from("users").select("*");
// Insert with tenant contextconst { data, error } = await tenantClient .from("posts") .insert({ title: "Hello", content: "World", tenant_id: "tenant-uuid" });Specifying Tenant Context
Section titled “Specifying Tenant Context”Tenant context is resolved in this priority order:
X-FB-Tenantheader - Explicit tenant override (validated against user’s membership)- JWT claims -
tenant_idandtenant_rolefrom the auth token - Default tenant - Falls back to
platform.tenants WHERE is_default = true
# Explicit tenant via headercurl -H "X-FB-Tenant: acme-corp" \ -H "Authorization: Bearer <service-key>" \ http://localhost:8080/api/v1/tables/posts
# Or use a tenant-scoped service key (tenant_id is embedded in the key)curl -H "Authorization: Bearer <tenant-service-key>" \ http://localhost:8080/api/v1/tables/postsRow Level Security
Section titled “Row Level Security”Tenant isolation is enforced through PostgreSQL RLS policies using the app.current_tenant_id session variable:
Tenant Service Role
Section titled “Tenant Service Role”The tenant_service role is used for tenant-scoped operations:
-- Example RLS policy for tenant isolationCREATE POLICY tenant_isolation ON public.postsFOR ALLTO tenant_serviceUSING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);Role Mapping for Multi-Tenancy
Section titled “Role Mapping for Multi-Tenancy”| Dashboard Role | PostgreSQL Role | RLS Behavior |
|---|---|---|
anon | anon | Public data only |
authenticated | authenticated | Own data only (via auth.uid()) |
tenant_admin | authenticated | Own data + tenant management (scoped via header) |
tenant_service | tenant_service | All data within tenant (via app.current_tenant_id) |
instance_admin | service_role | All data across all tenants (bypasses RLS) |
Adding Tenant Columns
Section titled “Adding Tenant Columns”All tenant-scoped tables should have a tenant_id column:
ALTER TABLE your_tableADD COLUMN tenant_id UUID REFERENCES platform.tenants(id) ON DELETE CASCADE;
CREATE INDEX idx_your_table_tenant_id ON your_table(tenant_id);RLS Policy Template
Section titled “RLS Policy Template”-- Enable RLSALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
-- Tenant service can only see their tenant's dataCREATE POLICY tenant_isolation ON your_tableFOR ALL TO tenant_serviceUSING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);Tenant-Specific Configuration
Section titled “Tenant-Specific Configuration”Per-tenant configuration overrides let each tenant have its own settings for authentication, storage, email, and other services. All settings also have FLUXBASE_* environment variable equivalents.
Configuration Hierarchy
Section titled “Configuration Hierarchy”Values are resolved in this order (highest priority last):
- Hardcoded defaults - Built-in default values
- Base YAML file -
fluxbase.yamlconfiguration - Tenant YAML files -
tenants/*.yamlfiles - Base environment variables -
FLUXBASE_*variables - Tenant-specific env vars -
FLUXBASE_TENANTS__<SLUG>__<SECTION>__<KEY>variables
Overridable Sections
Section titled “Overridable Sections”The following configuration sections can be overridden per-tenant:
| Section | Description |
|---|---|
auth | JWT secret, expiry, OAuth providers |
storage | Provider (local/S3), bucket, region |
email | Provider (SMTP/SES/SendGrid), from address |
functions | Timeout, memory limits |
jobs | Worker count, queue settings |
ai | Model, embedding settings |
realtime | WebSocket connection limits |
api | Page size limits |
graphql | Query depth limits |
rpc | Procedure execution limits |
Instance-level settings (database, server, CORS, metrics, logging) remain global.
Inline Tenant Configs
Section titled “Inline Tenant Configs”Define tenant overrides directly in fluxbase.yaml:
# Base configuration (applies to all tenants)auth: jwt_secret: "base-secret-change-in-production" jwt_expiry: "15m"
storage: provider: "local" local_path: "./storage"
# Tenant-specific overridestenants: default: name: "Platform"
configs: acme-corp: auth: jwt_secret: "${ACME_JWT_SECRET}" # From environment variable jwt_expiry: "30m" storage: provider: "s3" s3_bucket: "acme-fluxbase" s3_region: "us-east-1" email: from_address: "noreply@acme.com"
beta-corp: auth: jwt_expiry: "1h" functions: default_timeout: 60 # secondsTenant Config Files
Section titled “Tenant Config Files”For GitOps-friendly workflows, store tenant configs in separate YAML files:
tenants: config_dir: "./tenants" # Load tenants/*.yaml filesslug: acme-corpname: Acme Corporationmetadata: plan: enterprise billing_email: billing@acme.com
config: auth: jwt_secret: "${ACME_JWT_SECRET}" oauth_providers: - name: google enabled: true client_id: "${ACME_GOOGLE_CLIENT_ID}" client_secret: "${ACME_GOOGLE_CLIENT_SECRET}"
storage: provider: s3 s3_bucket: acme-fluxbase-prod s3_region: us-east-1
email: provider: ses from_address: noreply@acme.com ses_region: us-east-1Environment Variable Interpolation
Section titled “Environment Variable Interpolation”Tenant config files support ${VAR_NAME} syntax for environment variable expansion:
config: auth: jwt_secret: "${JWT_SECRET}" # Replaced with JWT_SECRET env var storage: s3_access_key: "${AWS_ACCESS_KEY_ID}" s3_secret_key: "${AWS_SECRET_ACCESS_KEY}"Tenant-Specific JWT Secrets
Section titled “Tenant-Specific JWT Secrets”Each tenant can have its own JWT secret, allowing complete cryptographic isolation:
tenants: configs: tenant-a: auth: jwt_secret: "tenant-a-secret-at-least-32-characters!" tenant-b: auth: jwt_secret: "tenant-b-secret-at-least-32-characters!"When a request includes tenant context (via X-FB-Tenant header or JWT claims), tokens are validated using the tenant-specific secret.
Storage Isolation
Section titled “Storage Isolation”Configure different storage backends per tenant:
tenants: configs: # EU tenant with EU data residency eu-customer: storage: provider: s3 s3_bucket: eu-customer-data s3_region: eu-west-1
# US tenant with US data residency us-customer: storage: provider: s3 s3_bucket: us-customer-data s3_region: us-east-1Instance-Level Settings & Tenant Settings
Section titled “Instance-Level Settings & Tenant Settings”Instance-level settings are stored in platform.instance_settings. Tenant-specific overrides can be managed through the Admin API and dashboard.
Managing Settings via API
Section titled “Managing Settings via API”Use the tenant settings API endpoints to manage per-tenant configuration:
# Get tenant settingscurl -H "Authorization: Bearer <service-key>" \ http://localhost:8080/api/v1/admin/tenants/<tenant-id>/settings
# Update tenant settingcurl -X PATCH -H "Authorization: Bearer <service-key>" \ -H "Content-Type: application/json" \ -d '{"settings": {"storage.max_upload_size": 104857600}}' \ http://localhost:8080/api/v1/admin/tenants/<tenant-id>/settingsTenant Declarative Schemas
Section titled “Tenant Declarative Schemas”Fluxbase supports declarative schema management for tenant databases. This allows you to define your tenant’s database schema in SQL files that are automatically applied when a tenant is created or on server startup.
Declarative Schema Configuration
Section titled “Declarative Schema Configuration”Enable tenant declarative schemas in your fluxbase.yaml:
tenants: declarative: enabled: true schema_dir: "./schemas" # Directory containing tenant schema files on_create: true # Apply schemas when creating a new tenant database on_startup: false # Apply schemas on server startup (for existing tenants) allow_destructive: false # Allow destructive schema changes (DROP, ALTER)Schema File Structure
Section titled “Schema File Structure”Tenant schema files are organized by tenant slug:
schemas/├── acme-corp/│ └── public.sql # Schema for acme-corp tenant's public schema├── beta-corp/│ └── public.sql # Schema for beta-corp tenant's public schema└── default/ └── public.sql # Schema for default tenant (if using separate database)Example Schema File
Section titled “Example Schema File”Create schemas/acme-corp/public.sql:
-- Create tables for the acme-corp tenantCREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT, created_at TIMESTAMPTZ DEFAULT now());
CREATE TABLE IF NOT EXISTS posts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, content TEXT, created_at TIMESTAMPTZ DEFAULT now());
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);How It Works
Section titled “How It Works”- On Tenant Creation: When a new tenant with a separate database is created, Fluxbase checks for a schema file in
{schema_dir}/{tenant-slug}/public.sql - Schema Application: If a schema file exists, it’s applied to the tenant’s database using diff-based planning (only changes are applied)
- Fingerprint Tracking: Applied schemas are tracked by fingerprint (SHA256 hash) in the
platform.tenant_declarative_statetable - Idempotent Application: Schemas are only re-applied if the fingerprint changes
API Endpoints
Section titled “API Endpoints”Manage tenant schemas via the Admin API:
# Get schema status for a tenantcurl -H "Authorization: Bearer <service-key>" \ http://localhost:8080/api/v1/admin/tenants/<tenant-id>/schema
# Apply schema for a specific tenant (from filesystem)curl -X POST -H "Authorization: Bearer <service-key>" \ http://localhost:8080/api/v1/admin/tenants/<tenant-id>/schema/apply
# Upload and apply schema contentcurl -X POST -H "Authorization: Bearer <service-key>" \ -H "Content-Type: application/json" \ -d '{"schema_sql": "CREATE TABLE ..."}' \ http://localhost:8080/api/v1/admin/tenants/<tenant-id>/schema/content/apply
# Get stored schema contentcurl -H "Authorization: Bearer <service-key>" \ http://localhost:8080/api/v1/admin/tenants/<tenant-id>/schema/content
# Delete stored schema contentcurl -X DELETE -H "Authorization: Bearer <service-key>" \ http://localhost:8080/api/v1/admin/tenants/<tenant-id>/schema/contentTenant-Scoped Branching
Section titled “Tenant-Scoped Branching”When database branching is enabled alongside multi-tenancy, branches clone from the tenant’s database (not the main database). After cloning, the FDW user mapping is automatically repaired. Each branch gets its own PostgreSQL database, and connection pool routing is: branch pool > tenant pool > main pool. See Database Branching for full details.
Troubleshooting
Section titled “Troubleshooting”Empty Results with Tenant Key
Section titled “Empty Results with Tenant Key”If queries return empty results:
- Verify the key is active:
SELECT is_active FROM platform.service_keys WHERE id = 'key-id' - Check tenant_id column exists on the table
- Verify RLS policy exists and uses
app.current_tenant_idsetting - Ensure you’re including
tenant_idin insert payloads for user tables
Cross-Tenant Data Access
Section titled “Cross-Tenant Data Access”If a tenant sees another tenant’s data:
- Check RLS is enabled:
SELECT rowsecurity FROM pg_tables WHERE tablename = 'your_table' - Verify policy uses correct session variable (
app.current_tenant_id) - Ensure queries are using tenant service key, not global
- Check that the tenant’s FDW role has
NOBYPASSRLSset
FDW Connection Issues
Section titled “FDW Connection Issues”If a tenant database can’t access shared services:
- Verify the FDW role exists:
SELECT * FROM pg_roles WHERE rolname LIKE 'fdw_tenant_%' - Check user mappings:
\deu+in the tenant database - Run tenant repair to re-setup FDW:
POST /api/v1/admin/tenants/<id>/repair - Check that
app.current_tenant_idis set on the FDW role:SELECT rolname, rolconfig FROM pg_roles WHERE rolname LIKE 'fdw_tenant_%'
Key Rotation Issues
Section titled “Key Rotation Issues”If old key still works after grace period:
- Check
grace_period_ends_attimestamp - Verify
revoked_atis set after grace period - Check
is_activeis false
Related Documentation
Section titled “Related Documentation”- Row Level Security - Detailed RLS implementation
- Database Branching - Branching with multi-tenancy
- Admin SDK - Admin API reference
- Configuration - Configuration options