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