Skip to main content

Single-tenant per shop

Each shop is a complete, independent deployment. There is no shared database between shops. There is no tenant_id column anywhere in any table. Shop B's data is, by construction, unreachable from Shop A's Worker.

Drafted from planning · v0.1
This is the architectural primitive that makes the Doctrine possible

The Doctrine's fourth removed constraint — "vendors wouldn't allow it" — collapses precisely because of single-tenant: per-shop drift on the surface can't break another shop, because there is no other shop in the same database. Multi-tenant SaaS cannot do PSaaS without re-architecting; we get it for free.

The shortest version

One shop → one Worker, one D1, one R2 bucket, one hostname. The Worker's bindings only point at its shop's resources. There is no software path from Shop A's runtime to Shop B's data.

Most SaaS does the opposite: one big database with a tenant_id column on every table, queries scoped by that column. That works at scale, but it puts the strength of the isolation in the application layer — one missing WHERE tenant_id = ? and customer A sees customer B's records. Helm doesn't take that bet.

Why this matters more for a bike-shop POS

Three reasons single-tenant beats multi-tenant for this specific product:

Trust. The shop owner is handing Kvick their entire customer database, their entire financial record, their inventory. The promise "your data lives in its own database that no other shop's code can reach" is a stronger promise than "your data is filtered by tenant_id." Stronger promises win deals with small business owners who've been burned before.

Compliance is easier. Data residency, GDPR-style erasure, audit access for a specific shop — all simpler when "their data" is a single database file. Exporting Shop A's data is wrangler d1 export helm-a-db. Deleting Shop A's data is wrangler d1 delete helm-a-db. No queries to write, no risk of leaving rows behind.

Failure isolation. A query mistake that locks Shop A's database — or a deployment bug that breaks Shop A's Worker — affects only Shop A. Shop B is on entirely different infrastructure.

What this means in practice

No tenant_id, shop_id, or customer_id_global columns

Read any migration in migrations/*.sql. You will find id, customer_id, staff_id, created_at — but nothing identifying the shop. The schema looks like a standalone application's schema, because that's exactly what it is per deployment.

Per-shop bindings in wrangler.jsonc

{
"env": {
"swicked": {
"name": "helm-swicked",
"d1_databases": [{ "binding": "DB", "database_id": "swicked-db-uuid" }],
"r2_buckets": [{ "binding": "ASSETS", "bucket_name": "helm-swicked-assets" }]
},
"mike-s-bikes": {
"name": "helm-mike-s-bikes",
"d1_databases": [{ "binding": "DB", "database_id": "mikes-db-uuid" }],
"r2_buckets": [{ "binding": "ASSETS", "bucket_name": "helm-mike-s-bikes-assets" }]
}
}
}

The Worker code is identical between deployments. The bindings differ.

Deploy = clone + rebind

A new shop deploy is wrangler d1 create helm-{new}-db, wrangler r2 bucket create helm-{new}-assets, wrangler d1 migrations apply helm-{new}-db, then wrangler deploy --env {new}. About 5-10 minutes today; target 2 minutes with scripts/onboard-shop.sh.

Backups are per-shop

Daily D1 export per shop, stored in that shop's R2 bucket. A single shop restore is wrangler d1 import helm-{shop}-db backup.sql.

What it costs us

Single-tenant has costs. Worth naming honestly:

Schema migrations run N times. Adding a column means running the migration against every shop's D1. The deploy script handles this, but it's not free — a slow migration multiplied by 50 shops is 50 × slow. We keep migrations fast (no ALTER TABLE ADD COLUMN on huge tables; use new tables + backfill scripts for those cases).

No cross-shop queries. Aggregate analytics across shops require either (a) a separate analytics pipeline that copies data into a multi-tenant store, or (b) per-shop queries fanned out and merged. We don't have either yet; "multi-shop reporting" is out of scope until we have 10+ shops to make it worth building.

Cost-per-shop floor. Cloudflare's free tiers apply per account, not per Worker. There's a small minimum cost per deployed Worker (mostly bytes-in/out). At low traffic it's negligible (~$2/shop/month estimated); at zero traffic it's near-zero. Multi-tenant would amortize this floor across all shops sharing one Worker.

Per-shop ops surface. N Workers is N things to monitor. We mitigate with the observability principle: every shop's Worker emits a small set of structured metrics tagged with the shop slug, aggregated in one dashboard.

When this principle bends

A few exceptions where shared infrastructure is OK:

  • The bible (this site) is single-tenant for Kvick, not per shop. Owners read it but don't write to it.
  • Webhook receivers from external services (Stripe, GBP, Meta) might need a small shared router that dispatches by shop slug. The receiver is shared; the data lands in the right shop's D1.
  • The AI corpus index of the bible is shared between shops (it's identical content). Each shop's Worker gets its own copy via KV cache. Not really an exception — the bible has no shop-specific data.

These exceptions don't violate the principle for shop data. They handle a different class of data (Kvick-internal or shared static reference).

What we explicitly rejected

  • Multi-tenant with tenant_id everywhere — simpler ops, weaker security, weaker compliance story. Lost on the trust argument.
  • Schema-per-tenant in one Postgres — feels closer to single-tenant but actually has all the multi-tenant ops complexity (one DB instance, shared compute, shared cache) without the benefit (one schema migration still has to run for all tenants). Worst-of-both.
  • One Worker, many D1s — would work as a halfway house. We chose full Worker-per-shop because the Worker code differs per-shop in small ways (branded UI, shop-specific tweaks) and per-shop deploys give per-shop rollback.

See also