Database-per-tenant setup
The hardest isolation, wired and provisioned - a separate database per tenant on PostgreSQL. Copy-pasteable integration-agent prompt plus the one-line swap for every ORM.
Most stack pages here default to PostgreSQL row-level with forced RLS. This page is the other end of the spectrum: a separate database per tenant - physically separate data, per-tenant backups, no noisy neighbours, and the natural fit when tenants are few and heavy or compliance demands separation. It works for a new or existing app - the integration-agent prompt below assesses your app first, integrates with your existing models and auth, and finishes with an isolated leak test. This is the PostgreSQL-focused guide; for MySQL specifics see MySQL database-per-tenant.
Database-per-tenant is the strongest isolation TenancyJS offers - each tenant scope runs on its own connection, so any query is isolated by construction. It's also the most operational overhead: more databases to provision, migrate, monitor, and back up. Works on PostgreSQL and MySQL (and MongoDB via Mongoose).
How it works
Each tenant record carries an opaque databaseKey - a handle to its database, never a URL or
credentials. You supply a connection: (tenant) => ({ key, create }) resolver (database: on
Mongoose) that maps that key to a host-created client. TenancyJS routes each tenant scope to its own
client through a bounded, single-flight connection cache:
- Bounded + LRU - connections are pooled and evicted, so you don't exhaust the database. Idle
connections are disposed on eviction, so always provide a
close/dispose callback. - Single-flight - concurrent requests for the same tenant share one in-flight creation.
- Collision-guarded - a 1:1 tenant↔placement guard rejects any cross-placement collision, so two tenants can never share a connection.
- Fail-closed - cross-placement access is rejected;
validate()returns a warning here because the connection factories are exercised lazily, not eagerly.
Central (cross-tenant / admin) work runs against the landlord connection explicitly via
manager.runInCentralContext. Creating and migrating each tenant's database
(CREATE DATABASE + migrate) is application-owned - run through your
provisioner hooks, never by the router at request time.
Integration-agent prompt
Worked for Express + TypeORM, database-per-tenant on PostgreSQL; the ORM section below shows the one-line swap for Prisma, Knex, Lucid, Sequelize, Drizzle, and Mongoose. Paste this into an assistant that can edit your repo and run commands - it assesses your app first, integrates with what already exists, and finishes with an isolated leak test.
# TenancyJS Integration Agent - Express 5 + TypeORM, database-per-tenant (PostgreSQL)
You are an integration agent. Wire fail-closed, database-per-tenant multi-tenancy into THIS Express +
TypeORM app on PostgreSQL with TenancyJS, integrating with whatever already exists - do NOT assume a
greenfield app. Execute steps in order; after each, verify it applied before continuing. If a step does
not apply to this repo, say so and adapt. Do NOT invent or assume API names, versions, or enum values, and never assert them from memory (your training data may be stale - library versions change). Verify against the real thing FIRST, then claim: `npm view <pkg> version` for a version, and the installed package's own types (in node_modules) or the linked docs for exact signatures and literal option values (e.g. the strategy is the string `"rowLevel"`).
Source of truth:
- https://tenancyjs.pages.dev/docs/adapters/typeorm
- https://tenancyjs.pages.dev/docs/strategies/database-per-tenant
- https://tenancyjs.pages.dev/docs/guides/provisioning/typeorm
- https://tenancyjs.pages.dev/docs/integrations/express
- https://tenancyjs.pages.dev/docs/guides/testing-isolation
## Step 0 - Assess the app (change nothing yet)
- Report the Node version (abort if < 24), the `express` and `typeorm` versions, and confirm PostgreSQL
and the `pg` driver are available. If this is not an Express + TypeORM + Postgres app, stop.
- Inventory the TypeORM entities: list every entity and mark each as tenant-owned or global/central.
- Find how the current user is determined per request (`req.user`, session, JWT) - the `authorize` hook
will need it.
- Detect any EXISTING tenant concept (a `tenantId`/`orgId` column, a workspaces/orgs table, or existing
per-tenant databases). If one exists, REUSE it - do not add a parallel one.
- Summarize what you found + the integration plan, and confirm the ISOLATION STRATEGY with the user:
database-per-tenant (this page) vs row-level vs schema-per-tenant. If database-per-tenant does not
fit, point them to https://tenancyjs.pages.dev/docs/strategies/choosing and stop.
## Step 1 - Install
`npm install tenancyjs-core tenancyjs-adapter-typeorm tenancyjs-integration-express tenancyjs-identifiers typeorm pg`
Verify the install succeeded before continuing.
## Step 2 - Placement on the tenant record
Each tenant row must carry a `databaseKey` - an opaque handle to its database, NEVER a URL or
credentials. The store's `find(identifier)` returns `[{ tenant: { id, databaseKey }, status }]`.
Verify the field exists and is opaque before continuing.
## Step 3 - tenancy.ts
- One exported `TenancyManager<Tenant>`.
- A `landlord` TypeORM `DataSource` for central work.
- A `createInitializedDataSource(databaseKey)` factory that returns a fully INITIALIZED tenant
DataSource registering EVERY entity (call `.initialize()` before returning it).
- The adapter in database-per-tenant mode:
```
const tenancy = createTypeOrmTenancy({
manager,
dataSource: landlord,
strategy: "databasePerTenant",
// List EVERY existing tenant-owned entity here; only add a new example entity if the app has none yet.
tenantEntities: [
{ entity: Post, table: "posts", tenantProperty: "tenantId", tenantColumn: "tenant_id" },
],
connection: (tenant) => ({
key: tenant.databaseKey, // opaque; never a URL
create: () => createInitializedDataSource(tenant.databaseKey),
}),
});
```
ALWAYS provide a dispose (`close`/`destroy`) so the cache evicts idle tenant DataSources. Never use
the landlord DataSource inside a tenant scope.
- A `TenantResolutionChain` (HeaderTenantResolver + the store above).
Verify the file type-checks before continuing.
## Step 4 - Express wiring
`app.use(createExpressTenancyMiddleware({ manager, resolver }))`; add an error handler for
`ExpressTenancyResolutionError` (400/404/500). Inside a request, query INSIDE a scope:
`tenancy.run((client) => client.repository(Post).findBy({}))`. Verify before continuing.
## Step 5 - Provision each tenant's database
Implement the provisioner hooks (see the TypeORM provisioning recipe): `provision` runs
`CREATE DATABASE "tenant_x"` on a privileged admin connection (NOT in a transaction); `migrate` runs
your TypeORM migrations against the tenant database; `deprovision` drops it. Then:
`npx tenancyjs-cli tenant create acme --set databaseKey=tenant_acme` -> `npx tenancyjs-cli tenant provision acme`
-> `npx tenancyjs-cli tenant migrate acme`. Verify each tenant's database exists and is migrated. If the
app already has existing tenants, provision and migrate a database for EACH of them before cutover - do
not leave an existing tenant without a database.
## Step 6 - Central work
Cross-tenant / admin work runs against the landlord explicitly via `manager.runInCentralContext(...)`.
Never run tenant-aware queries with no scope. Verify before continuing.
## Step 7 - Verify isolation (an isolated test - it does NOT touch app data)
Two TEST tenants in two TEST databases (never real app tenants), same primary key: write a row under
each, assert querying as A never returns B's row, and that a tenant query with no scope THROWS. Follow
the testing-isolation guide.
## Step 8 - Roll out + verify
- `npm run build`, boot the app, run the two-tenant test. Report every file changed and any issue.
- Existing app: provision + migrate a database for every existing tenant before cutover, then roll out
incrementally - scope routes behind the middleware, keep admin/cross-tenant work in
`runInCentralContext`, and treat any thrown unscoped-access error as a route you still need to scope.The one-line swap for other ORMs
Same shape - a connection(tenant) (or database(tenant)) resolver under
strategy: "databasePerTenant", always with a dispose callback. Only the adapter factory and the
provisioning DDL change:
| ORM | Adapter (database-per-tenant) | Provisioning recipe | Databases |
|---|---|---|---|
| Prisma | createPrismaDatabaseTenancy({ manager, connection }) - per-tenant leased PrismaClient (disconnect disposes) | Prisma → | PostgreSQL · MySQL |
| Knex | createKnexTenancy({ manager, knex, strategy: "databasePerTenant", connection }) | Knex → | PostgreSQL |
| Lucid | createLucidTenancy({ manager, database, strategy: "databasePerTenant", connection }) - create returns { transaction, destroy } | Lucid → | PostgreSQL · MySQL |
| Sequelize | createSequelizeTenancy({ manager, sequelize, strategy: "databasePerTenant", tenantModels, connection }) | Sequelize → | PostgreSQL · MySQL |
| Drizzle | createDrizzleTenancy({ manager, database, strategy: "databasePerTenant", tenantTables, connection }) - bind with createPostgresDrizzleBinding / createMySqlDrizzleBinding (supply close) | Drizzle → | PostgreSQL · MySQL |
| Mongoose | createMongooseTenancy({ manager, connection, strategy: "databasePerTenant", tenantModels, database: (tenant) => ({ key, create }) }) | Mongoose → | MongoDB (replica set) |
On MySQL, SCHEMA is a synonym for DATABASE, so database-per-tenant is MySQL's schema-isolation
equivalent - see the MySQL page for mysql2 /
dialect: "mysql" specifics. On MongoDB, each tenant is isolated per database and connections must
reach a replica set. The full create/migrate/drop hooks per ORM live in the
provisioning recipes.
Full query freedom
Because each tenant scope runs on its own connection, the facade's raw/join/nested restrictions buy no
safety - so this is the one strategy where you can reach for the real, tenant-scoped client:
client.unrestricted() (Knex, TypeORM, Sequelize, Drizzle, Mongoose), scope.unrestricted() on Lucid,
and Prisma hands you the leased client directly. It stays fail-closed: it throws unless a per-tenant
connection was actually leased (so it throws in central mode). See
the strategy doc.
Where to go next
- Database-per-tenant strategy - the cache, collision guarantees, and central behaviour in depth.
- MySQL database-per-tenant - the MySQL-specific sibling.
- Provisioning recipes - the create/migrate/drop hooks per ORM.
- Testing isolation - prove it with a two-tenant leak test before you trust it.