nSelf ships two distinct isolation mechanisms. Choosing the wrong one is a silent data-leak. This page explains the Convention Wall and when to use each.
The Convention Wall — Read This First
nSelf ships two distinct multi-tenancy mechanisms. They are not interchangeable. Using the wrong one causes silent data leaks across customers or apps.
source_account_id TEXT
Multi-App Isolation
Separates independent consumer apps within one nSelf deploy. Default: 'primary'.
Use when: running nchat + nclaw + ntask on the same Postgres instance.
tenant_id UUID
Cloud SaaS Tenancy
Separates paying customers in nSelf Cloud. Nullable — only set for Cloud customers.
Use when: you are nSelf Cloud, running a multi-tenant managed service.
Rule: NEVER use source_account_id to separate Cloud customers. NEVER use tenant_id for multi-app isolation within a single self-hosted deploy.
# Multi-app isolation: multiple apps on one backend
cd nchat/.backend && nself start # source_account_id = 'nchat'
cd nclaw/.backend && nself start # source_account_id = 'nclaw'
# Both share one Postgres + one Hasura. Isolated by source_account_id.
# Cloud SaaS tenant management (nSelf Cloud operators only)
nself tenant create --name "Acme Corp" --plan pro
nself tenant list
nself tenant get acme-corp
nself tenant update acme-corp --plan enterprise
nself tenant suspend acme-corp
nself tenant delete acme-corp # Soft deleteSelf-hosted deploy (one Postgres):
App: nchat App: nclaw
source_account_id='nchat' source_account_id='nclaw'
| |
+----------+---------------+
| (same Hasura, same Postgres)
v
+-------------------------------------+
| PostgreSQL 16 |
| |
| np_users (source_account_id) |
| +-- 'nchat' rows (chat users) |
| +-- 'nclaw' rows (claw users) |
+-------------------------------------+
nSelf Cloud (same Postgres, adds tenant_id):
Tenant: acme-corp Tenant: beta-inc
tenant_id=uuid-A tenant_id=uuid-B
| |
+-----------+-------------+
v
+-------------------------------------+
| PostgreSQL 16 |
| |
| np_users (tenant_id) |
| +-- uuid-A rows (Acme users) |
| +-- uuid-B rows (Beta users) |
+-------------------------------------+Every np_* table carries source_account_id. This separates data between independent apps that share one nSelf backend. A self-hosters running nchat and nclaw on the same VPS uses this column to keep their data apart.
CREATE TABLE np_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_account_id TEXT NOT NULL DEFAULT 'primary',
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
-- email unique per app, not globally
UNIQUE (email, source_account_id)
);
-- Most queries filter by source_account_id first
CREATE INDEX idx_np_users_source ON np_users (source_account_id, id)
WHERE deleted_at IS NULL;ALTER TABLE np_users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "source_account_isolation" ON np_users
USING (
source_account_id = current_setting('app.source_account_id', true)
);
-- Set context per connection:
SET app.source_account_id = 'nchat';
SELECT * FROM np_users; -- returns only nchat users// table permission — SELECT for app role
{
"source_account_id": {
"_eq": "X-Hasura-Source-Account-Id"
}
}
// JWT claims:
{
"https://hasura.io/jwt/claims": {
"x-hasura-role": "user",
"x-hasura-source-account-id": "nchat"
}
}# In each app's .env.dev
SOURCE_ACCOUNT_ID=nchat # For nchat
SOURCE_ACCOUNT_ID=nclaw # For nclaw
SOURCE_ACCOUNT_ID=primary # Single-app deploys (default)Used exclusively by nSelf Cloud — the managed multi-tenant hosting service at cloud.nself.org. Self-hosters do not use tenant_id.
CREATE TABLE np_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_account_id TEXT NOT NULL DEFAULT 'primary', -- still required
tenant_id UUID, -- Cloud only
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE np_tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Always index tenant_id
CREATE INDEX idx_np_users_tenant ON np_users (tenant_id)
WHERE tenant_id IS NOT NULL;ALTER TABLE np_users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON np_users
USING (
tenant_id = (
current_setting('hasura.user', true)::json->>'x-hasura-tenant-id'
)::uuid
);// Must be present on every np_* table with tenant_id
{
"tenant_id": {
"_eq": "X-Hasura-Tenant-Id"
}
}
// JWT claims for Cloud customer
{
"https://hasura.io/jwt/claims": {
"x-hasura-role": "tenant-user",
"x-hasura-tenant-id": "uuid-of-acme-corp",
"x-hasura-source-account-id": "primary"
}
}nself tenant create --name "Acme Corp" --slug acme-corp --plan pro --email admin@acme.com
nself tenant list
nself tenant list --format json
nself tenant get acme-corp
nself tenant get acme-corp --include-usage
nself tenant update acme-corp --plan enterprise
# Member management
nself tenant member add acme-corp --email dev@acme.com --role admin
nself tenant member remove acme-corp --email dev@acme.com
# Lifecycle
nself tenant suspend acme-corp --reason "payment_failed"
nself tenant activate acme-corp
nself tenant delete acme-corp # Soft delete (keeps data 30d)
nself tenant delete acme-corp --hard --confirm # Permanent purgeFor strong compliance isolation (SOC 2, HIPAA), nSelf Cloud supports schema-per-tenant. Each tenant gets a dedicated PostgreSQL schema with the same np_* table structure.
# Create tenant with schema isolation
nself tenant create acme-corp --isolation schema
# Migrate all tenant schemas to latest
nself db migrate --all-tenants
# Run query in a specific tenant schema
nself db query --tenant acme-corp "SELECT count(*) FROM np_users"-- nself build generates this automatically
CREATE SCHEMA tenant_acme_corp;
CREATE TABLE tenant_acme_corp.np_users (LIKE public.np_users INCLUDING ALL);
CREATE TABLE tenant_acme_corp.np_documents (LIKE public.np_documents INCLUDING ALL);# Custom domain for a Cloud tenant
nself tenant domain add acme-corp --domain app.acme.com
nself tenant domain remove acme-corp --domain app.acme.com
nself tenant domain list acme-corp# Tenant-specific branding (env vars per tenant)
TENANT_LOGO_URL=https://acme.com/logo.png
TENANT_PRIMARY_COLOR=#0ea5e9
TENANT_APP_NAME="Acme Portal"
TENANT_SUPPORT_EMAIL=support@acme.comRequest: acme-corp.cloud.nself.org
|
v
Nginx (wildcard *.cloud.nself.org)
|
+-- Extracts subdomain: acme-corp
+-- Looks up tenant_id from slug (np_tenants)
+-- Injects X-Tenant-ID header
|
v
Hasura (uses X-Tenant-ID in JWT claims)
|
v
PostgreSQL (RLS: tenant_id = X-Tenant-ID)# Usage report
nself tenant usage acme-corp
nself tenant usage acme-corp --from 2026-05-01 --to 2026-05-31
nself tenant usage --all --format csv > usage-may-2026.csv
# Billing sync (requires stripe plugin)
nself plugin install stripe
nself tenant billing sync acme-corpCREATE TABLE np_tenant_usage (
tenant_id UUID NOT NULL REFERENCES np_tenants(id),
metric TEXT NOT NULL, -- 'api_calls' | 'storage_bytes' | 'users'
value BIGINT NOT NULL DEFAULT 0,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT tenant_id, metric, sum(value) AS total
FROM np_tenant_usage
WHERE period_start >= date_trunc('month', NOW())
GROUP BY tenant_id, metric
ORDER BY total DESC;# Export all tenant data
nself tenant export acme-corp --format json --output acme-export.json
nself tenant export acme-corp --format csv --output acme-export.zip
nself tenant export acme-corp --tables np_users,np_documents
nself tenant export acme-corp --verify# nself doctor checks PERM-RLS-01 automatically on every deploy
nself doctor --deep --check PERM-RLS-01
# Checks:
# - Every np_* table has RLS enabled
# - Every table with tenant_id has tenant_isolation policy
# - Every table with source_account_id has source_account_isolation policy
# - No table uses tenant_id as substitute for source_account_id
# - Hasura row filters match RLS policiesAm I nSelf Cloud (running cloud.nself.org)?
YES -> Use tenant_id for customer isolation.
source_account_id is still required (default 'primary').
NO -> Do NOT use tenant_id. Leave it NULL.
Am I running multiple apps (nchat, nclaw, ntask) on one Postgres?
YES -> Use source_account_id. Set to app name ('nchat', 'nclaw', etc.)
NO -> source_account_id defaults to 'primary'. No action needed.
Do I need schema-level isolation for compliance (SOC 2, HIPAA)?
YES -> Use schema-per-tenant (Cloud only).
NO -> Row-level isolation is sufficient.nself doctor --deep --check PERM-RLS-01 after every migration — catches missing policies before production.source_account_id and tenant_id — every query filters by these columns first.SET ROLE — simulate a different tenant context and verify cross-tenant data is invisible.tenant_id from the client — always derive it server-side from the JWT claim.