Database
PostgreSQL connection, schema push & multi-tenancy
The database block is the one piece of configuration no Nucleus app can boot without. It tells the framework where your PostgreSQL instance lives and how to project your entity definitions onto real tables.
Nucleus speaks to Postgres through Drizzle ORM. On startup it loads the generated schema file, ensures the target schema exists, then runs drizzle-kit's pushSchema to reconcile the live database with your declarations — creating tables and columns so you never hand-write a migration for routine changes.
The same block also unlocks multi-tenancy: flip one boolean and every tenant gets its own fully isolated Postgres schema, resolved per-request from the subdomain or a header.
Connection#
How Nucleus reaches your database. The connection string is never written inline — you store the name of an environment variable, and Nucleus resolves it at boot. If that variable is missing the process fails fast with a clear error, so a misconfigured deploy never starts in a half-broken state.
1{2 "database": {3 "url": "DATABASE_URL",4 "type": "postgres",5 "schemas": ["main"]6 }7}urlstringOptionalThe NAME of an environment variable that holds the full PostgreSQL connection string (e.g. "DATABASE_URL"). Nucleus reads process.env[url] at startup — it does not treat this value as the literal DSN. Before connecting it calls ensureDatabaseExists so a brand-new database is created automatically if absent.
Example: "DATABASE_URL" → postgres://user:pass@host:5432/db
pool{ max?; idleTimeoutMillis?; connectionTimeoutMillis?; maxUses? }OptionalTunes the shared bounded Postgres connection pool (max, idleTimeoutMillis, connectionTimeoutMillis, maxUses). One shared pool is used across the app rather than a pool per request — the fix for Postgres error 53300 (too many clients) under load. Leave unset for sensible defaults.
type'postgres'OptionalThe database engine. PostgreSQL is the only supported and tested target — the entire schema-push, multi-schema and JSON column strategy assumes Postgres.
"postgres"Schema synchronization#
You never write CREATE TABLE by hand. You declare entities (see the Entities section); a generator turns them into a Drizzle schema file, and on every boot Nucleus pushes that schema to the chosen Postgres schema. The first element of schemas is the primary schema everything lands in.
schemasstring[]OptionalThe list of Postgres schemas Nucleus manages. schemas[0] is the primary (target) schema — all system and entity tables are created there, and it defaults to "main" when omitted. In multi-tenant mode this primary acts as the control-plane schema while each tenant receives its own.
Example: ["main"]
["main"]allowDataLossbooleanOptionalSafety switch for destructive schema changes. When a push contains any data-loss DDL and allowDataLoss is false, Nucleus SKIPS the whole sync for that schema (logging an error) rather than applying it partially — a purely additive push still applies normally. Set true only when you deliberately want the sync to apply destructive DDL (dropping removed columns/tables) to match your entities exactly.
falseschemaPushTimeoutMsnumberOptionalDeadline (in milliseconds) for the boot-time drizzle-kit push. drizzle-kit's programmatic pushSchema can raise an interactive rename prompt — it appears when a push adds a new table while another table exists in the database but not in the generated schema — and that prompt can never be answered without a TTY, so without a deadline the pod would hang mid-boot with no output. On timeout the sync for that schema is abandoned with an explanatory error (suggesting you drop the stale table or split the change across two deploys) and the app keeps booting.
120000Multi-tenancy#
Nucleus implements tenancy as full per-schema isolation: every tenant gets its own Postgres schema containing the complete set of tables (users, sessions, roles, your entities — everything). There is no shared users table and no row-level tenant_id smell. The primary schema doubles as the control plane that tracks all tenants.
isMultiTenantbooleanOptionalMaster switch for tenancy. When true, Nucleus initialises a TenantRegistry after the main schema syncs and exposes tenant-management routes. The registry itself does NO seeding and no DDL — at boot it only LOADS existing tenants and builds their in-memory schema contexts; the plugin then pushes each tenant's table set inline (DDL push per schema — ensureSchemaExists + pushSchema). Seeding is done by the plugin: once authorization is enabled it seeds the MAIN schema, then (in multi-tenant mode) loops over EVERY pre-existing tenant schema — not main-only — and re-seeds each one at boot: claims (seedClaims, when autoSeedClaims is on), the godmin role/user (setupGodmin — idempotent, so it only inserts what is missing, but it IS re-run at every boot for each tenant whose record has a godAdminEmail while authorization.godminPassword is configured), and any custom seed (runSeed). Tenant seed failures are logged as warnings, not fatal. The separate onTenantProvisioned hook covers the runtime self-signup / provision path for a brand-new tenant and deliberately does NOT create a godmin there. It also lights up the tenant endpoints in the generated client.
falsetenantResolution'subdomain' | 'header' | 'both'OptionalHow an incoming request is mapped to a tenant. subdomain inspects the host (acme.yourapp.com → tenant acme); header reads a request header; both tries the subdomain first and falls back to the header. Resolution is served synchronously from in-memory indices built at startup (initialize) and kept coherent by refreshTenant() / invalidateCache() — there is no per-request Redis lookup.
subdomain— Resolve purely from the host's leftmost label.header— Resolve from the tenant header only.both— Subdomain first, then header as a fallback (default).
"both"defaultTrustedSources{ allowHeaderAuth?; allowedIps?; allowedServices? }[]OptionalDeployment-level fallback for header-auth trust used when a tenant defines none of its own: each entry allows header-based tenant/user auth (allowHeaderAuth) from specific allowedIps / allowedServices. Without a match, spoofable auth headers are not trusted.
tenantHeaderstringOptionalThe header inspected when tenantResolution includes "header". The registry tries its value as a subdomain first, then a tenant ID, then a schema name before giving up. Header resolution is additionally gated by the tenant's trustedSources (or database.defaultTrustedSources) — an untrusted source is rejected with 403.
"x-tenant-id"sharedSchemastringOptionalExplicit name of the primary / control-plane (main) schema. It takes precedence over schemas[0]; when both are unset the main schema is named "main". This is where the tenants and tenant_features tables live and where non-tenant-scoped requests fall back — it is NOT a per-tenant "shared data" schema (tenant schemas are fully isolated). Effective name resolves as sharedSchema || schemas[0] || "main".
Under the hood — the managers#
Two things do the real work behind this config: a single shared connection pool that the plugin owns directly, and — in multi-tenant mode — the TenantRegistry. You never instantiate them yourself, but knowing how they behave explains the config's guarantees.
Shared pg Poolone bounded pool per instanceOptionalThe plugin creates ONE node-postgres Pool for the whole app (all tenant schemas included — the schema is switched logically per query, not by per-tenant pools) and hands it to Drizzle. Bound it via database.pool (max default 10, idleTimeoutMillis 30000, connectionTimeoutMillis 10000). Before the pool opens, the standalone ensureDatabaseExists() runs, creating the target database if it is absent (failures are warnings, not fatal). Under Entra ID auth the pool's password is an async token getter, so rotating credentials are fetched and refreshed at connect time. Entra ID / service-principal Postgres auth is switched on via the DATABASE_AUTH_MODE env var — accepted values 'password' (default), 'managed_identity', 'service_principal'; managed_identity requires AZURE_CLIENT_ID (only a boot-time warning if unset); service_principal additionally requires AZURE_TENANT_ID and AZURE_CLIENT_SECRET, whose absence is a FATAL config-validation error that aborts boot. (The separate PostgreSQLManager helper class exists in src/Managers/Postgre but is NOT on the boot path — it is never instantiated.)
TenantRegistry.initialize()startupOptionalRegisters the main schema context, loads every row from the tenants and tenant_features tables, builds in-memory indices (by subdomain, schema name and id) and constructs a Drizzle schema context for each active tenant — each context is a pgSchema(name) carrying the complete table set produced by createAllTablesForSchema.
syncSchemaToDb()DDL pushOptionalPer-schema reconciliation: dynamically imports drizzle-kit/api's pushSchema, scopes it to the target schema and applies the push. It is the push used on the live provisioning paths by provisionTenant (and by syncAllSchemas / syncFromIdp — the consumer-mode polling path that pushes DDL for newly discovered active tenants; initializeFromIdp itself pushes nothing). refreshTenant does NOT call it — that path only rebuilds the in-memory context via buildSchemaContext and pushes no schema. At BOOT, however, the plugin does the pushes inline rather than calling this method: it pushes the main schema first (ensureSchemaExists + pushSchema + applySchemaPush), then TenantRegistry.initialize() / initializeFromIdp() builds each active tenant context and the plugin loops over the tenant schema names pushing each one inline the same way — a push failure is logged as a warning, not a fatal error.
provisionTenant()runtimeOptionalCreating a tenant runs ensureSchemaExists (CREATE SCHEMA) → buildSchemaContext → indexTenant → syncSchemaToDb, so a brand-new tenant gets the full isolated table set on the fly. After edits, refreshTenant() reloads a tenant from the DB and rebuilds its in-memory context (re-indexing + buildSchemaContext); invalidateCache() only evicts the tenant's Redis key (tenant:{subdomain}), NOT the in-memory indices. Request resolution is served from the in-memory indices, never a per-request Redis lookup.
Related sections