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
- Provision — Stripe checkout webhook → control plane writes
tenantsrow + creates a new volume + starts a container with theghcr.io/lycato/cli:latestimage, env wired to volume path and a freshly-minted KMS DEK. - 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). - Active —
lycato serveruns inside the container. Edge routes the founder's domain. - Suspend — failed billing or founder-initiated. Container stops; volume preserved.
- 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
- 1-20 tenants: single VM, Docker compose, 4 vCPU, 16GB RAM.
- 20-100 tenants: Fly.io org with per-tenant Fly machines.
- 100-1000: introduce a tenant scheduler that suspends idle containers (hourly check + 24h idle threshold) and resumes on request (warm < 5s). Cuts compute cost by ~5x for active-monthly founder usage patterns.
- 1000+: re-evaluate request-multiplexed multi-tenant. At that point we have data to justify it.
What's intentionally absent
- Per-region tenant placement. EU-only at launch; US later.
- Tenant migration tools. v1.x.
- Tenant snapshot rollback UI. Founder's own GitHub repo is the rollback today.