Configure PostgreSQL Schema-Based Multi-Tenancy
After completing this guide you will have a working PostgreSQL multi-tenant setup where each tenant’s workflows run in an isolated database schema, with optional per-tenant database credentials for defense-in-depth security.
Prerequisites
Section titled “Prerequisites”- Cloacina added to your project (
cloacina = "0.12"inCargo.toml) - A running PostgreSQL server
- For per-tenant credentials: an admin user with
CREATEDBandCREATEROLEprivileges
1. Create per-tenant executors with DefaultRunner::with_schema
Section titled “1. Create per-tenant executors with DefaultRunner::with_schema”Each call to DefaultRunner::with_schema provisions a PostgreSQL schema (if it does not already exist) and returns an executor scoped to that schema:
use cloacina::runner::DefaultRunner;
let database_url = "postgresql://user:pass@localhost/cloacina";
let tenant_a = DefaultRunner::with_schema(database_url, "tenant_a").await?;let tenant_b = DefaultRunner::with_schema(database_url, "tenant_b").await?;All workflow state for tenant_a is fully isolated from tenant_b.
2. Provision tenants with DatabaseAdmin
Section titled “2. Provision tenants with DatabaseAdmin”Use DatabaseAdmin to create a schema and a dedicated database user in one step. Passing an empty password triggers secure auto-generation:
use cloacina::database::{Database, DatabaseAdmin, TenantConfig};
let admin_db = Database::new( "postgresql://admin:admin_pass@localhost/cloacina", "cloacina", 10,);let admin = DatabaseAdmin::new(admin_db);
let creds = admin.create_tenant(TenantConfig { schema_name: "tenant_secure".to_string(), username: "secure_user".to_string(), password: "".to_string(), // auto-generate})?;
// creds exposes: username, password, schema_name, connection_stringStore the returned TenantCredentials in a secrets manager (e.g., HashiCorp Vault, AWS Secrets Manager) and pass the connection string when creating the executor:
let executor = DefaultRunner::with_schema( &creds.connection_string, &creds.schema_name,).await?;To tear down a tenant and its database user:
admin.remove_tenant(&creds.schema_name, &creds.username)?;Why per-tenant credentials?
Section titled “Why per-tenant credentials?”- Database-level isolation – each tenant can only access their own schema
- Audit compliance – PostgreSQL logs attribute operations to the correct user
- Independent credential rotation – rotate one tenant without affecting others
3. Verify tenant isolation
Section titled “3. Verify tenant isolation”A minimal check that two tenants cannot see each other’s executions:
let tenant_a = DefaultRunner::with_schema(database_url, "test_tenant_a").await?;let tenant_b = DefaultRunner::with_schema(database_url, "test_tenant_b").await?;
let result_a = tenant_a.execute_async("test_workflow", Context::new()).await?;let result_b = tenant_b.execute_async("test_workflow", Context::new()).await?;
assert_ne!(result_a.execution_id, result_b.execution_id);For full integration-testing patterns, see Testing Workflows.
Alternative: SQLite file-based tenancy
Section titled “Alternative: SQLite file-based tenancy”If you do not need PostgreSQL, you can isolate tenants with one SQLite file per tenant:
let executor = DefaultRunner::new("sqlite:///data/tenant_a.db").await?;Each file is a self-contained database, so isolation is guaranteed by the file system. DatabaseAdmin and per-tenant credentials are not available for SQLite.
Migrate an existing single-tenant deployment
Section titled “Migrate an existing single-tenant deployment”If you already run a single-tenant deployment against the public schema, you
can move to schema-based tenancy without starting over. Two approaches:
Option 1: Move existing data into a named schema
Section titled “Option 1: Move existing data into a named schema”Move the existing tables into a dedicated schema, then address that schema as a tenant. Run this in a transaction:
BEGIN;CREATE SCHEMA legacy_tenant;
ALTER TABLE pipeline_executions SET SCHEMA legacy_tenant;ALTER TABLE task_executions SET SCHEMA legacy_tenant;ALTER TABLE contexts SET SCHEMA legacy_tenant;-- ... repeat for all Cloacina tables
COMMIT;Then point the existing application at the new schema, and give new tenants their own schemas:
let legacy = DefaultRunner::with_schema(db_url, "legacy_tenant").await?;let new_tenant = DefaultRunner::with_schema(db_url, "new_customer").await?;Option 2: Run side-by-side
Section titled “Option 2: Run side-by-side”Leave the existing single-tenant runner on the public schema and add new
tenants alongside it in their own schemas:
// Existing single-tenant runner (public schema)let legacy = DefaultRunner::new(db_url).await?;
// New multi-tenant runnerslet tenant_a = DefaultRunner::with_schema(db_url, "tenant_a").await?;let tenant_b = DefaultRunner::with_schema(db_url, "tenant_b").await?;Schema names must follow the schema naming rules.
Related guides
Section titled “Related guides”- Multi-Tenant Recovery – automatic recovery and migration in multi-tenant deployments
- Testing Workflows – integration testing patterns including tenant isolation tests
- Performance Tuning – connection pool sizing, concurrency limits, and monitoring