Multi-Tenancy
Fullfinity supports multi-tenant deployments where each tenant has its own isolated database.
Architecture
Section titled “Architecture”┌─────────────────────────────────────────────────────────┐│ Single Codebase │├─────────────────────────────────────────────────────────┤│ tenant1.example.com tenant2.example.com tenant3.com ││ │ │ │ ││ ▼ ▼ ▼ ││ ┌─────────┐ ┌─────────┐ ┌─────────┐ ││ │ DB: t1 │ │ DB: t2 │ │ DB: t3 │ ││ │ Modules:│ │ Modules:│ │ Modules:│ ││ │ - core │ │ - core │ │ - core │ ││ │ - crm │ │ - crm │ │ - crm │ ││ │ - inv │ │ │ │ - inv │ ││ └─────────┘ └─────────┘ └─────────┘ │└─────────────────────────────────────────────────────────┘Benefits
Section titled “Benefits”- Data Isolation - Each tenant’s data is in a separate database
- Independent Modules - Tenants can install different modules
- Customization - Views can be customized per tenant
- Security - Complete data separation
- Scalability - Databases can be on different servers
How It Works
Section titled “How It Works”1. Tenant Routing
Section titled “1. Tenant Routing”Tenants are identified by subdomain or host header:
# Request to tenant1.example.comtenant = get_tenant_from_host(request.headers.get("host"))# tenant = "tenant1"2. Database Connection
Section titled “2. Database Connection”Each request uses the tenant’s database pool:
# Get connection pool for tenantpool = await db_manager.get_pool(tenant_db_name)3. Connection Pooling
Section titled “3. Connection Pooling”Pools are maintained per database:
# Pools are cached and reusedif tenant_db not in connection_pools: connection_pools[tenant_db] = await create_pool(tenant_db)Configuration
Section titled “Configuration”Selecting the tenant database
Section titled “Selecting the tenant database”There is no multi_tenant config block. Multi-tenancy is inherent: every request resolves a
database (by host/subdomain, the database selector, or the db cookie), and each database is a
fully isolated tenant. The one relevant config key is DB_FILTER in config.yaml, which
constrains which database(s) a deployment will serve:
DB_FILTER: acme # pin the deployment to a single tenant database# DB_FILTER: none # (default) serve any database; let host/selector chooseTenant Database Setup
Section titled “Tenant Database Setup”Each tenant needs a database:
-- Create tenant databaseCREATE DATABASE tenant_acme;# Initialize/upgrade the tenant's schema (the CLI is `fullfinity-server`)fullfinity-server -c config.yaml -db tenant_acme -u allModule Installation Per Tenant
Section titled “Module Installation Per Tenant”Each tenant can install different modules:
# Install module for specific tenantawait install_module(tenant_db="tenant_acme", module_name="crm")The module’s:
- Models are created in the tenant’s database
- Views are stored in the tenant’s
ui_viewtable - Security rules are added to the tenant’s
model_accesstable
View Customization Per Tenant
Section titled “View Customization Per Tenant”Views stored in the database can be customized:
# Tenant-specific view modificationview = await UiView.filter(identifier="contact_form_view").first()view.arch[0]["content"].append(custom_field)await view.save()Data Migration
Section titled “Data Migration”Export Tenant Data
Section titled “Export Tenant Data”pg_dump -h localhost -U postgres tenant_acme > tenant_acme_backup.sqlImport to New Tenant
Section titled “Import to New Tenant”createdb tenant_newpsql -h localhost -U postgres tenant_new < tenant_acme_backup.sqlCopying a tenant
Section titled “Copying a tenant”To duplicate a database from Python — seeding a sandbox from a prepared master, standing up a
throwaway copy — use clone_database, which copies both halves of a tenant:
from fullfinity.engine.api.database import clone_database, delete_database
await clone_database("tenant_master", "tenant_sandbox")...await delete_database("tenant_sandbox")It issues CREATE DATABASE ... TEMPLATE, so PostgreSQL copies the source’s files directly —
seconds where a dump piped into a restore takes minutes — and it duplicates the source’s
filestore. That second half is not optional: attachments are files on disk, not rows, so a copy
made with pg_dump alone resolves every image and document to a 404 while the database itself
looks perfectly healthy.
How the files are duplicated depends on where they live, but both routes leave the clone owning its own files — so afterwards the two behave identically: the source can be dropped while the clone keeps everything, and deleting from either touches only its own.
On local disk they are hardlinked rather than copied: one set of bytes, a second set of names, refcounted by the filesystem. A multi-gigabyte filestore therefore costs no extra disk and no measurable time. Where the filesystem cannot link — a filestore root on a different device — it falls back to a real copy automatically.
In object storage the objects are copied server-side and concurrently, so the bytes never travel through the application. Both are safe for the same reason: files are content-addressed — a name is the checksum of its contents, nothing is ever rewritten in place — so storing a new file in the copy writes a new checksum, and deleting one there removes only that copy’s name.
Which files belong to which database
Section titled “Which files belong to which database”A file’s key begins with one segment identifying the database that owns it:
<store_id>/ab/cd/<sha256-of-the-contents>store_id is not the database’s name. It is minted when the database is created, stored inside
that database, and never derived from anything renameable — because a name is not an identity. It
can be changed by restoring under a different one, reused by a later database, and is embedded in
every key already written. Keyed by name, restoring acme as acme_staging put every byte where
the restored database would never look; two nodes each holding a database called production and
sharing one bucket were a single prefix, each serving the other’s files.
Create, clone and restore each mint a new id, because each produces a database that must own
its files rather than share another’s. Reading it is await env.filestore_id(); nothing else should
construct a key.
Considerations
Section titled “Considerations”Performance
Section titled “Performance”- Connection pools are per-database
- Cache keys include database name
- Queries are isolated per tenant
Security
Section titled “Security”- No cross-tenant data access
- Tenant context verified on each request
- Database credentials can differ per tenant
Maintenance
Section titled “Maintenance”- Migrations run per database
- Module updates apply to all tenants
- Database backups are independent
Common Patterns
Section titled “Common Patterns”Shared Master Data
Section titled “Shared Master Data”For data shared across tenants (e.g., countries, currencies):
- Store in a shared database
- Replicate to tenant databases on sync
- Or use read-only access to shared database
Tenant Provisioning
Section titled “Tenant Provisioning”async def create_tenant(name: str, admin_email: str): # 1. Create database await create_database(f"tenant_{name}")
# 2. Run schema setup await run_setup(f"tenant_{name}")
# 3. Install core module await install_module(f"tenant_{name}", "core")
# 4. Create admin user await create_user(f"tenant_{name}", admin_email)Next Steps
Section titled “Next Steps”- Module System - How modules work with multi-tenancy