Multi-Tenant Design (Operational, Not Logical)

Status: design. Target: ADR-0028.

The model

We are single-tenant per container, with an operationally multi-tenant control plane that orchestrates many single-tenant deployments.

Why: tenant isolation is physical. One compromised container can't read another tenant's volume. No request-multiplexed bugs (you can fix the worst bug class by not having it).

Why not request-multiplexed multi-tenant: at the founder/team segment, our tenant count is bounded by acquisition rate, not by per-tenant compute cost. A 5-20 tenant first cohort fits comfortably on a single VM; 100 tenants on a small cluster. We don't earn multi-tenant complexity yet.

Topology

                ┌────────────────────────────────────┐
                │  Control plane (Fly.io / Render)   │
                │  - Postgres: tenants, sessions     │
                │  - Stripe webhook receiver         │
                │  - Provisioner worker              │
                │  - Magic-link sender               │
                └─────────┬──────────────────────────┘
                          │  spawns + GC's
                          ▼
   ┌──────────────────────┴──────────────────────┐
   │  Tenant pool (per-tenant containers)        │
   │                                             │
   │  tenant_a:  caddy → fastify(127.0.0.1:3001) │
   │             volume: vol_tenant_a            │
   │  tenant_b:  caddy → fastify(127.0.0.1:3001) │
   │             volume: vol_tenant_b            │
   │  ...                                        │
   └─────────────────────────────────────────────┘

Edge router (Caddy on the control plane) maps <slug>.Lycato → tenant container by tenant slug.

Tenant lifecycle

  1. Provision — Stripe checkout webhook → control plane writes tenants row + creates a new volume + starts a container with the ghcr.io/lycato/cli:latest image, env wired to volume path and a freshly-minted KMS DEK.
  2. Init — container's first boot runs lycato init --hosted (a hosted-flavor of init that skips the wizard prompts and uses data from the Stripe payload).
  3. Activelycato serve runs inside the container. Edge routes the founder's domain.
  4. Suspend — failed billing or founder-initiated. Container stops; volume preserved.
  5. Delete — 30 days after suspend OR founder-initiated. Volume destroyed. Last surviving copy is founder-owned GitHub repo (always pushed on every commit, see BACKUPS.md).

Tenant DB schema (control plane)

CREATE TABLE tenants (
  id             uuid PRIMARY KEY,
  slug           text UNIQUE NOT NULL,           -- e.g. "acesense"
  email          text NOT NULL,
  plan           text NOT NULL,                  -- founder | team
  status         text NOT NULL,                  -- active | suspended | deleting
  stripe_customer_id text,
  kms_key_id     text NOT NULL,                  -- per-tenant KEK
  volume_id      text NOT NULL,                  -- platform-specific
  github_repo    text,                           -- founder-owned mirror
  created_at     timestamptz NOT NULL,
  suspended_at   timestamptz,
  deleted_at     timestamptz
);
CREATE TABLE sessions (
  id          text PRIMARY KEY,                  -- opaque
  tenant_id   uuid NOT NULL REFERENCES tenants(id),
  user_email  text NOT NULL,
  role        text NOT NULL,                     -- owner | reviewer
  created_at  timestamptz NOT NULL,
  last_seen_at timestamptz NOT NULL,
  expires_at  timestamptz NOT NULL
);
CREATE TABLE audit_ops (
  id           uuid PRIMARY KEY,
  tenant_id    uuid NOT NULL,
  actor        text NOT NULL,                    -- operator email or "system"
  action       text NOT NULL,                    -- provision | restart | restore | delete
  at           timestamptz NOT NULL,
  detail_json  jsonb
);

No tenant data ever lives in the control plane DB. The control plane knows tenants exist and how to reach them; it never reads their volume.

Routing

Edge Caddy config (generated by control plane on tenant provision/suspend):

<slug>.Lycato {
  encode zstd gzip
  reverse_proxy tenant-<id>.lycato.internal:3001
  @suspended `{tenant.status} == "suspended"`
  handle @suspended { redirect "/billing-failed" 302 }
}

Scaling beyond the first 100 tenants

What's intentionally absent