Skip to content

ADR-0023: Container and tenant isolation

  • Status: Accepted
  • Date: 2026-08-17
  • Deciders: El, Lachlan
  • Supersedes: ADR-0014 §5, §6, §7

Context

ADR-0007 settled the shape — shared core stack, one Compose stack per tenant. This ADR records how isolation is actually enforced, and adds Metabase, which did not exist as a component when ADR-0014 was written.

Decision

  1. Docker: one Compose file per stack (core = one; each tenant = one, templated). Per-tenant private networks plus a shared-core network. restart: unless-stopped. Per-container resource limits.
  2. Image versions are pinned. No latest, anywhere. Upgrades are an explicit, reviewable commit. This is the mechanism behind scheduled patching: unattended-upgrades does not touch container images, so a bump that is not deliberate does not happen.
  3. Postgres: one instance per tenant, not a shared instance with tenant_id rows. Isolation is the container and volume boundary. A principal column for row-level security is in the schema from day one, enforced later (ADR-0017). Named volumes on the main disk; block storage is a later option. pgvector-enabled image, single pinned major version across tenants.
  4. n8n: one instance per tenant, each with its own dedicated Postgres. The Projects feature is not used — this is licence compliance (managed hosting on the client's behalf, not reselling n8n as a service), not a technical preference. n8n calls the brain over the internal network only, with tenant ID in every call. External webhooks arrive via Caddy; n8n is never directly exposed.
  5. Metabase is conditional, driven by the tenant's services list. It is deployed only for clients who bought the dashboard product — it is a per-product maintenance cost sized into that product's price, not baseline tenant infrastructure. Automation-only clients never receive a container, and never pay ~2 GB of RAM for one.
  6. One Metabase instance per tenant, never one shared across tenants. A shared instance would need credentials to every client database, collapsing the isolation boundary into a single compromise.
  7. Metabase connects on a read-only database user against the serving views only — never the application user, never the modelled layer directly.
  8. Metabase has a CVE history including pre-auth RCE. Its pinned version requires a scheduled bump in the provisioning playbook (ADR-0024). This cost scales with the number of dashboard clients and is accepted as the price of per-tenant isolation.

Consequences

Isolation is a container and network boundary rather than an application-level filter, so a query bug cannot cross tenants. Infrastructure cost scales roughly linearly with tenants — a known cost-model input, traded for isolation and licence compliance. Metabase being conditional keeps automation-only clients cheap. Pinned images mean upgrades are deliberate work rather than something that happens by accident on restart; that is the point, and the ongoing cost.

Correction required: AGENTS.md currently states "n8n project per tenant." That contradicts §4. The ADR is authoritative; AGENTS.md is corrected to "one n8n instance per tenant; Projects not used."

Alternatives considered

  • Shared n8n using Projects — rejected: licensing, and a shared credential boundary.
  • Shared Postgres with tenant_id rows — rejected: weaker isolation than the container boundary (ADR-0007).
  • Kubernetes — rejected: premature complexity at this scale (ADR-0007).
  • One shared Metabase across tenants — rejected: requires credentials to every client database.
  • Metabase for every tenant by default — rejected: 2 GB of JVM per client who never asked for a dashboard.