Multi-tenancy — tenant isolation, per-tenant DBs, context & timezone
The whole tenancy model reduces to one idea:
Every tenant gets its own pair of OS processes and its own MongoDB database. There is no shared application database for business data and no row-level
tenantIdon business collections — isolation is at the database level, enforced by giving each tenant process its ownmongodb_url. A small platform-level master DB holds the registry of tenants and their limits/feature whitelist.
Source: zerp-be/CLAUDE.md, zerp-be/src/modules/tenant-config, zerp-be/src/context.ts, zerp-be/src/core/database. Related: architecture, auth, company-branch, config, subscription/features.
1. Process & database model
┌─────────────────────────── master / control plane (zerp-master) ───────────────────────────┐
│ master MongoDB → tenants collection (key, limits, feature whitelist, suspended flag, TZ) │
└───────────────────────────────────────────────────────────────────────────────────────────┘
│ starts/stops/deletes PM2 processes, runs migrations (SSE)
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
tenant "atlantic" tenant "acme" tenant "…"
zerp-atlantic-be ◀──┐ zerp-acme-be ◀──┐
(own mongodb_url) │ (own mongodb_url) │ each: be + admin PM2 pair,
zerp-atlantic-admin ─┘ zerp-acme-admin ──┘ own DB, own ports, own TZ
- One PM2 process per tenant per app — e.g.
zerp-atlantic-be(:4060)+zerp-atlantic-admin(:…). Started/stopped/deleted from the zerp-master control plane. - One MongoDB database per tenant (DB name = tenant key, no prefix by default). Business data never crosses tenants because the connection itself is tenant-scoped.
- Master DB is connected to separately via
mongodb_master_url; it holds thetenantscollection (limits, feature whitelist, suspended status) consumed at boot. - The admin process targets its tenant's backend GraphQL endpoint at runtime via
NEXT_PUBLIC_SERVER_URL.
Required backend environment variables
| Var | Purpose |
|---|---|
mongodb_url |
This tenant's own database |
mongodb_master_url |
Platform/master DB (for TenantConfigService) |
tenant_key |
Tenant identifier |
internal_master_secret |
Shared secret for master → tenant RPCs (e.g. feature-cache clear) |
port |
HTTP port |
TZ |
IANA timezone (optional; host default if absent) |
app_env |
local / dev / production — controls Swagger + cache TTLs |
2. Tenant config (loaded at boot)
TenantConfigService reads this tenant's row from the master DB tenants collection at boot and caches it. It supplies:
- Limits —
maxCompanies,maxUsers,maxEmployee. Hard-enforced in company/user/employee create paths. - Feature whitelist — the set of features this tenant is allowed, intersected with the subscription plan catalog by the feature resolver (see subscription/features).
development_modedefaults todedicated, which ignores the subscription and grants the full catalog. - Suspended flag — a suspended tenant is blocked.
The master can push a cache-clear RPC to a tenant process (authenticated with internal_master_secret) when its config changes.
3. Two levels of partitioning
There are two distinct partition concepts — don't confuse them:
- Tenant (database-level) — described above. A whole isolated installation. No business collection carries a tenant id.
- Company / branch / store (row-level, within a tenant's DB) — a single tenant may run multiple companies/subsidiaries, each with branches and stores. Business documents carry
companyId(and oftenbranchId). This scoping is injected automatically bycore/database/database.repository.tsfromcontextSvc.companyId. See company-branch. (Branch-level read filtering exists in code but is currently commented-out/dormant; company-level scoping is active.)
So: tenant → companies → branches → stores → documents.
4. Per-request context
ApContextMiddleware populates ApContextService (src/context.ts) for every request from the authenticated token (see auth):
userId,companyId,branchId, role/permission info,employeeId(for ESS).- Services and repositories read
contextSvc.companyId/contextSvc.branchIdon every query/mutation. Never hardcode these.
5. Timezone correctness (project-critical rule)
Each tenant process receives a TZ env var so new Date() / moment() / dayjs() resolve in the tenant's local timezone. The contract:
- The frontend sends date-range boundaries as pre-aligned millisecond timestamps (already aligned to the user's TZ).
- The backend passes them through as-is. With
TZset,moment()agrees with the frontend boundary. - Do NOT re-apply
moment(ts).startOf('day')/endOf('day')on backend-received timestamps. Doing so shifts the boundary by the server→tenant offset and causes off-by-one-day bugs for non-UTC tenants. Do not readTZmanually — rely on Node's built-in behavior.
6. Migrations
One-off scripts in zerp-be/src/migrations/ (e.g. 2026-04-14-fix-cross-company-categoryids.ts) are invoked per-tenant from zerp-master's Migrate button (streamed over SSE). Rules: idempotent (safe to re-run), read mongodb_url from env (never hardcode), log progress, exit non-zero on failure.