Skip to content

Multi-Tenancy

Fullfinity supports multi-tenant deployments where each tenant has its own isolated database.

┌─────────────────────────────────────────────────────────┐
│ 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 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────┘
  • 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

Tenants are identified by subdomain or host header:

# Request to tenant1.example.com
tenant = get_tenant_from_host(request.headers.get("host"))
# tenant = "tenant1"

Each request uses the tenant’s database pool:

# Get connection pool for tenant
pool = await db_manager.get_pool(tenant_db_name)

Pools are maintained per database:

# Pools are cached and reused
if tenant_db not in connection_pools:
connection_pools[tenant_db] = await create_pool(tenant_db)

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:

config.yaml
DB_FILTER: acme # pin the deployment to a single tenant database
# DB_FILTER: none # (default) serve any database; let host/selector choose

Each tenant needs a database:

-- Create tenant database
CREATE DATABASE tenant_acme;
Terminal window
# Initialize/upgrade the tenant's schema (the CLI is `fullfinity-server`)
fullfinity-server -c config.yaml -db tenant_acme -u all

Each tenant can install different modules:

# Install module for specific tenant
await 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_view table
  • Security rules are added to the tenant’s model_access table

Views stored in the database can be customized:

# Tenant-specific view modification
view = await UiView.filter(identifier="contact_form_view").first()
view.arch[0]["content"].append(custom_field)
await view.save()
Terminal window
pg_dump -h localhost -U postgres tenant_acme > tenant_acme_backup.sql
Terminal window
createdb tenant_new
psql -h localhost -U postgres tenant_new < tenant_acme_backup.sql

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.

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.

  • Connection pools are per-database
  • Cache keys include database name
  • Queries are isolated per tenant
  • No cross-tenant data access
  • Tenant context verified on each request
  • Database credentials can differ per tenant
  • Migrations run per database
  • Module updates apply to all tenants
  • Database backups are independent

For data shared across tenants (e.g., countries, currencies):

  1. Store in a shared database
  2. Replicate to tenant databases on sync
  3. Or use read-only access to shared database
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)