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: a shared core stack, with 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, so the core is one and each tenant is 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 and enforced later (ADR-0017). Named volumes on the main disk, with block storage as 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, on licence compliance grounds (managed hosting on the client's behalf, not reselling n8n as a service) rather than technical preference. n8n calls the brain over the internal network only, with the tenant ID in every call. External webhooks arrive via Caddy, and 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, being a per-product maintenance cost sized into that product's price rather than baseline tenant infrastructure. Automation-only clients never receive a container, and never pay around 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 and never the modelled layer directly.
  8. Metabase has a CVE history including pre-auth remote code execution. 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, which is 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.

Alternatives considered

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