Skip to main content

ADR-0007: One isolated stack per client instead of a multi-tenant database

Status: Accepted (retroactive); superseded for the hosted SaaS offering by ADR-0013 Date: 2026-07-24

The retrofit this ADR judged too risky was carried out on 2026-07-27, in stages, each one leaving the application working single-tenant. A per-client stack is still a supported deployment — it now runs with exactly one tenant in the shared schema. The statement below that "the schema has zero tenant scoping" no longer holds.

Context​

Selling the system to multiple companies required a tenancy model. The schema has zero tenant scoping — none of the 91 tables carries a tenant_id, and no query filters by tenant. Retrofitting row-level tenancy into a payroll schema (where a scoping bug leaks salaries across companies) would have been a large, risky rewrite touching every repository and specification, for a customer count measured in single digits on one VPS.

The alternative: keep the application single-tenant and isolate at the infrastructure layer instead.

Decision​

Every client gets their own full stack — Postgres + backend + frontend — from one template:

  • docker-compose.client.template.yml parameterized by CLIENT_SLUG; run with docker compose -p <slug> so containers, volumes, and networks are namespaced per client. No host ports are published; the only way in is the shared Traefik edge (ADR-0008), which routes <slug>.<domain> to that client's frontend.
  • scripts/onboard-client.sh provisions a client in minutes: generates .env.<slug> with fresh secrets (DB password, JWT secret, ALTCHA key), an nginx conf, and brings the stack up.
  • scripts/upgrade-all.sh rolls a new build to every client sequentially, gated on each stack's healthcheck; scripts/backup-all.sh dumps every client DB.
  • The application code remains 100% tenant-unaware — dev, demo, and every production stack run literally the same image with different env.

The full operating procedure is deployment/vps-guide.md.

Consequences​

Positive

  • Isolation is structural, not logical: cross-client data leakage would require a Docker-network or Traefik-routing failure, not a missing WHERE clause. For payroll data this is the strongest available guarantee.
  • Per-client secrets, backups, restores, and even downgrades are independent; one client's incident never touches another.
  • The application codebase stays radically simpler — no tenant context, no scoped queries, no per-tenant migrations.

Negative

  • Hard capacity ceiling per VPS: every client costs a full JVM (Spring Boot) plus a full Postgres. Client count scales by buying servers, not by adding rows.
  • Upgrade fan-out is sequential and O(clients): upgrade-all.sh loops stack by stack, waiting for each healthcheck; a fleet of N clients means N supervised restarts, and a mid-loop failure leaves the fleet on mixed versions.
  • Cross-client anything (aggregate analytics, a single admin view of all tenants) is impossible without new infrastructure.
  • If the business ever outgrows this model, adding tenant_id later means touching effectively the whole data layer — this ADR defers that cost, it does not remove it.

References​