Skip to content

Databases & Tenant Isolation

VSKI provides built-in multi-tenancy support, allowing you to manage multiple isolated databases within a single VSKI instance. This is perfect for SaaS applications, multi-environment setups, and client data isolation.

Note Schemas are append-only. You cannot rename columns through the interface. Dropping and renaming columns is a manual task.

Note Tables and columns are always soft-deleted when using the VSKI API. Data migration and breaking changes is an admin task that has to be done carefully and with a cool head.

Each VSKI instance can host multiple databases, each stored as a separate file. This ensures complete data isolation between tenants while sharing the same application code and server infrastructure.

data/
├── default.db # Default database
├── stats.db # Statistics database (reserved)
├── workflows.db # Workflows database (reserved)
├── client1.db # Client 1 database
├── client2.db # Client 2 database
└── client3.db # Client 3 database

img

Databases are ensure-exists resources in the IaC YAML (see IaC); they are never deleted by vski apply:

vski/databases.yaml
version: 1
databases:
client1:
description: Client 1 tenant
Terminal window
vski db create client1 --description "Client 1 tenant" # imperative alternative
database = await client.settings.databases.create({"name": "client1"})
print(database["name"]) # "client1"

This creates a new client1.db file in the data directory.

Terminal window
vski db list
databases = await client.settings.databases.list()
print(databases)
# [
# {"name": "default"},
# {"name": "stats"},
# {"name": "workflows"},
# {"name": "client1"},
# {"name": "client2"},
# ]

The recommended approach is client.db(name):

# Access a specific database
client1_db = client.db("client1")
# All operations now use client1.db
posts = await client1_db.collection("posts").get_list(1, 20)
# Access another database
client2_db = client.db("client2")
posts2 = await client2_db.collection("posts").get_list(1, 20)

client.set_db(name) also exists but switches the whole client.

When making direct HTTP requests, use the x-dbname header:

import httpx
response = httpx.get(
"http://127.0.0.1:3000/api/collections/posts/records",
headers={
"Authorization": "Bearer your-token",
"x-dbname": "client1",
},
)

Each database is completely isolated:

client1_db = client.db("client1")
await client1_db.collection("posts").create({"title": "Post in Client 1"})
client2_db = client.db("client2")
await client2_db.collection("posts").create({"title": "Post in Client 2"})
# switch back to client1
posts = await client1_db.collection("posts").get_list(1, 20)
print(len(posts["items"])) # 1: only the post created in client1
Terminal window
vski db rm client1
ok = await client.settings.databases.delete("client1") # True

Warning: Deleting a database permanently removes all its data.

VSKI reserves three database names:

The default database is used when no specific database is specified:

posts = await client.collection("posts").get_list(1, 20)

The stats database stores system statistics and logs:

stats_db = client.db("stats")
stats = await stats_db.collection("_stats").get_list(1, 20)

Do not store user data in the stats database.

The workflows database stores all workflow-related data:

  • Workflow runs and their status
  • Workflow events and signals
  • Job queues for distributed workers
  • Hooks for external integrations
  • Wait states for sleep/signal operations
workflows_db = client.db("workflows")
runs = await workflows_db.collection("workflow_runs").get_list(1, 20)

Do not store user data in the workflows database. This database is automatically omitted from replication.

async def register_tenant(client, tenant_data):
# Create tenant record in the default database
tenant = await client.db("default").collection("tenants").create(
{"name": tenant_data["name"],
"subdomain": tenant_data["subdomain"],
"status": "active"}
)
# Create isolated database for the tenant
await client.settings.databases.create({"name": tenant["id"]})
# Initialize the tenant's collections (see below)
await initialize_tenant_database(client, tenant["id"])
return tenant

Collections are declared once and apply across databases (schema is global; records are stored separately in each database file). With IaC, a tenant database reuses the same collection files with a different database: line:

# vski/tenants/client1/collections.yaml: same schema, scoped to the tenant db
version: 1
database: client1
collections:
users:
type: auth
fields:
- {name: email, type: email, required: true}
- {name: password, type: text, required: true}
- {name: name, type: text}
posts:
type: base
fields:
- {name: title, type: text, required: true}
- {name: content, type: text}
Terminal window
vski apply # creates the database entries and collections
async def login_tenant_user(client, tenant_id, email, password):
tenant_db = client.db(tenant_id)
result = await tenant_db.auth.login(email, password)
return result
import os
env = os.environ.get("NODE_ENV", "development")
databases = {
"development": "dev",
"staging": "staging",
"production": "prod",
}
env_db = client.db(databases[env])
vski/databases.yaml
version: 1
databases:
dev: {description: Development}
staging: {description: Staging}
prod: {description: Production}
dev_db = client.db("dev")
# All operations here are isolated from staging and prod

Set a custom directory for database files:

Terminal window
export DATA_DIR="/path/to/custom/data"
# Default location: ./data/
# Custom location via DATA_DIR environment variable
#
# Database files are:
# - default.db
# - client1.db
# - etc.

Scope per database with client.db(name) (records are isolated per database; schemas are shared):

shared = await client.db("shared").collection("shared_data").get_list(1, 20)
client1_db = client.db("client1")
stats = await client1_db.collection("_stats").get_list(1, 20)
print(stats["items"])

Check database file sizes on the server host:

from pathlib import Path
data_dir = Path("./data")
for file in sorted(data_dir.glob("*.db")):
print(f"{file.name}: {file.stat().st_size / 1024 / 1024:.2f} MB")
Terminal window
cp data/client1.db backups/client1.db # backup
cp backups/client1.db data/client1.db # restore (stop the server first)

See Backup for the snapshot-based rotation script.

MethodEndpointDescription
GET/api/databasesList all databases
POST/api/databasesCreate new database
DELETE/api/databases/:nameDelete database
  1. Use descriptive names: make database names clear and meaningful
  2. Isolate tenants completely: each tenant gets their own database
  3. Document database structure: keep track of which databases exist and their purpose (the IaC databases.yaml is that list)
  4. Implement proper backup: regular backup of all databases
  5. Monitor storage: track database file sizes
  6. Use reserved names carefully: only use default, stats, and workflows for their intended purposes
  7. Set proper permissions: ensure users can only access their own databases
  8. Test multi-tenancy: verify data isolation in your application