ADR-0008 — CMS deployment topology: per-site single-tenant worker fleet¶
Status: Accepted Date: 2026-07-05 Deciders: Cathal Dempsey Related: ADR-0004 (adapter pattern — resolves its "Multi-tenant deferral" followup), ADR-0005 (R2 keying — already tenant-safe), ADR-0006 (Cloudflare Access — becomes per-site apps)
Context¶
CLAUDE.md and apps/cms/README.md describe the CMS worker as
"multi-tenant" — one Cloudflare Worker + D1 + R2 + emdash serving
CMS-driven content for many sites. ADR-0004's "Multi-tenant deferral"
followup captured the runtime design that phrase implied: when
multiple sites share one worker, global.css flips from a build-time
file to a runtime D1 lookup, with tenant selection presumably keyed
off the request hostname.
That deferral now needs an answer, because the topology question is no longer hypothetical. The target is ~1800 sites, and all ~1800 may become self-service CMS-managed — site owners editing their own site through the emdash admin. Whatever topology we pick has to scale to the whole portfolio, not a hand-picked subset.
The forcing constraint on the "many workers" side is Cloudflare's ~500 Workers per account script-count ceiling. (The "500/month" figure sometimes cited in project notes is Cloudflare Pages builds per month — a different limit on a different product; don't conflate them.) 1800 sites does not fit in one account, but it fits comfortably in ~4.
Decision¶
Deploy the CMS as a fleet of single-tenant Cloudflare Workers — one
Worker per site — not as one shared multi-tenant Worker. Each site's
Worker is a parameterised copy of the current garvanbay setup: its own
D1 database, its own build-baked theme (emit-theme-css.mjs as
today), its own Cloudflare Access application (ADR-0006, now
instantiated per site). Workers shard across multiple Cloudflare
accounts — ~1800 sites over ~4 accounts at ~450 each — to stay under
the per-account script ceiling.
This explicitly resolves ADR-0004's deferral by choosing the opposite of the deferred design: not one worker serving many tenants at runtime, but many workers each serving one.
Why per-site wins¶
- emdash's data model has no tenant dimension. The read path
(
getEmDashEntry) and the admin write path queryec_pages/ec_globalsby slug + locale only — there is no site/tenant column (node_modules/emdash/src/loader.ts~686–698:WHERE (slug = ? AND locale = ?) OR id = ?). Two tenants in one D1 collide on slughome. A shared worker would therefore require forking or wrapping emdash's read and write paths to add tenant scoping — an ongoing maintenance cost against an upstream that moves fast (the same ~50-commits/week velocity that killed Path B in ADR-0004). Per-site workers keep every worker single-tenant, so the problem never arises. - No runtime theming needed. Each worker bakes exactly one site's theme at build time, as today. The runtime-CSS-injection work ADR-0004 flagged ("global.css must move to a runtime D1 lookup") is avoided entirely.
- Self-service isolation is free. Each site's own Worker + own Access app admitting only that site's owner is the tenant boundary. No per-tenant RBAC to engineer inside a shared worker — the edge does it.
- Blast-radius isolation. A bad deploy, a corrupted D1, or a runaway edit affects one site, not the fleet.
- R2 needs no change. Storage is already multi-tenant-safe
regardless of topology — keys are
<domain>/<key>-prefixed (ADR-0005 §2), one shared bucket, resolved viaPUBLIC_MEDIA_BASE.
Alternatives considered¶
Runtime host-routed shared worker. One worker; tenant chosen from the request hostname; shared D1 with a tenant column; theme injected at runtime from D1 (per the ADR-0004 deferral note); per-tenant RBAC inside the admin. This is the design the word "multi-tenant" was pointing at, and it does have a real benefit: one script, no account-sharding. Rejected because every one of its moving parts is work the per-site fleet gets for free — forking emdash's read+write paths for tenant scoping, runtime theme resolution, per-tenant auth — and the complexity lands against a fast-moving upstream we'd have to track fork-side forever. The benefit it buys (fewer worker scripts) is provided more cheaply by sharding sites across ~4 accounts.
Per-site worker fleet. Chosen, as above.
Consequences¶
Positive¶
- Every worker is single-tenant, so the whole class of tenant-scoping problems (data collisions, runtime theming, per-tenant RBAC) never exists. No emdash fork.
- Self-service isolation comes from the topology itself: one Access app per site, owner-only policy, done.
- Blast radius is one site.
- The theme stays a build artifact — the
emitGlobalCsscontract from ADR-0004's shared-theme followup is untouched; only the "storage flips at multi-tenant time" prediction is retired.
Negative / risks¶
- N of everything must be provisioned and operated. ~1800 Workers, ~1800 D1 databases, ~1800 Access applications. This demands deploy/provisioning automation (Slice 3) and account-sharding bookkeeping — which site lives on which account, with per-account API tokens. The D1 count is well under Cloudflare's per-account D1 limits, but it is a real ops surface.
- Shared-layer updates fan out. A change to the canonical block library or the CMS app shell means redeploying N workers, not one. Mitigated by the same automation Slice 3 builds — a fleet redeploy is a loop, not a design problem, but it has to exist.
- Supersession note: this ADR supersedes ADR-0004's deferred runtime-theme note for the CMS path. The "helper contract stays stable, only the storage flips" hedge is no longer pending work — the storage never flips.
Slice tracker¶
| Slice | Scope | Status |
|---|---|---|
| 1 — Parameterise tenant | De-hardcode garvanbay; emit-theme-css.mjs + seed selection + reseed + image-transform take a SITE param via a shared resolver (scripts/lib/resolve-site.mjs); garvanbay output byte-identical. |
Done 2026-07-05 |
| 2 — Templated wrangler + 2nd site | Per-site wrangler.jsonc generated from sites/<site>.jsonc + wrangler.template.jsonc (scripts/emit-wrangler.mjs); waterfordcountypainters deployed live as a second isolated worker at wcp.dcathal.org. Baked-seed made SITE-aware (scripts/emit-seed.mjs) so a fresh tenant auto-seeds its own content. |
Done 2026-07-06 |
| 3 — Provisioning + sharding automation | One command: given a site + target account → create D1, create Access app, template wrangler, deploy, reseed; account-assignment scheme. | Planned (next). Its hard blocker is CLOSED 2026-07-13 pt4 — see "Baked-seed identity assertion" below. |
| 4 — Self-service + fleet ops | Per-site owner-only Access policy; rebuild-on-edit; fleet monitoring. | Deferred |
Slice 2 execution notes (2026-07-06)¶
- Isolation proven, cross-account sharding deferred. Both garvanbay
(
emdash.dcathal.org) and waterfordcountypainters (wcp.dcathal.org) run as independent single-tenant workers on the same personal account (1ceb…). Two workers, two D1s, two Access apps, two themes, zero cross-contamination — but the cross-account half of the sharding claim (~450 sites/account × ~4 accounts) is unproven. See known-issues "Cross-account sharding unproven". - Fresh-tenant D1 bootstrap is a human
/_emdashlogin, not a headless command — emdash createsec_pages/ec_globalsonly when a seed is applied (runtime migrate + auto-seed on first admin hit). See known-patterns "Provisioning a fresh tenant D1 …". Slice 3 must either automate this login or reproducerunMigrations+applySeedagainst remote D1. - Baked-seed fix. The worker bakes
virtual:emdash/seedat build time; it readpackage.json#emdash.seed(garvanbay) regardless ofSITE, so the fresh WCP D1 first auto-seeded garvanbay content (cleared + reseeded by hand this session). Fixed byscripts/emit-seed.mjsemitting.emdash/seed.jsonfor the active site (precedence #1). See known-patterns "emdash bakes the seed …".
Baked-seed identity assertion — a build that would ship the wrong tenant is now impossible (2026-07-13 pt4)¶
Slice 2's execution notes record the baked-seed bug and its fix (scripts/emit-seed.mjs emits
.emdash/seed.json per SITE). That fix was necessary but not sufficient, and the artifact
silently rotted back. .emdash/seed.json is a gitignored build artifact, emit-seed ran
only in prebuild, and nothing asserted the baked file against SITE — so it held
whichever site was built last, and any build outside the npm lifecycle (npx astro build)
baked it as-is. On 2026-07-13 it held meta.name: "garvanbay", and the WCP worker had been
built from it — WCP's admin ran under garvanbay's name.
This is not cosmetic for the fleet: the runtime auto-seed imports the same baked module, so provisioning a fresh tenant against a stale artifact seeds the wrong client's content. It is precisely the failure Slice 2 already hit once ("the fresh WCP D1 first auto-seeded garvanbay content").
The assertion now lives in apps/cms/astro.config.mjs, at config load — every astro
build and astro dev loads the config however it is invoked, so prebuild can be bypassed
and this cannot. A missing or mismatched .emdash/seed.json refuses to build, naming
the site and the remedy. emit-seed.mjs was added to predev as well (it only ran in
prebuild, so astro dev on a named site would have silently used the default site's seed).
Proven: with a garvanbay-baked seed present, SITE=waterfordcountypainters refuses;
SITE=garvanbay builds. Slice 3 can no longer mint N workers carrying one site's identity.
Platform note — emdash 0.28.1 worker contract (2026-07-09)¶
The emdash upgrade slice (0.9.0 → 0.28.1) changed two fleet-template facts every future per-site worker inherits:
apps/cms/src/worker.tsmust beexport { default, PluginBridge } from "@emdash-cms/cloudflare/worker"andwrangler.template.jsoncmust carry"triggers": {"crons": ["* * * * *"]}(emdash ≥0.19.0 runs scheduled publishing + plugin cron via the Worker'sscheduled()handler — without the trigger they silently never run).- The emdash packages are exact-pinned lockstep (
emdash/@emdash-cms/admin/@emdash-cms/cloudflareat 0.28.1) — see known-patterns "The biggest version number on npm may not be the head". - Fresh-tenant D1 bootstrap (the Slice 2 note above) is unchanged at 0.28.1: runtime migrations run on first request; verified live on both workers (migrations 036–050 applied to 0.9.0-era databases with content intact).
When to revisit¶
- Per-account operational overhead becomes the bottleneck (N D1s, N Access apps, sharding bookkeeping) → reconsider Workers-for-Platforms dispatch namespaces, which lift the script ceiling entirely (unlimited scripts per namespace) and would replace account-sharding as the scaling mechanism.
- emdash ships native tenant-scoping → the runtime shared-worker option loses its biggest cost (the fork) and could be re-evaluated on its remaining merits.
References¶
- ADR-0004 — "Multi-tenant deferral" followup (resolved here)
- ADR-0005 §2 — R2 key shape (
<domain>/<key>, already tenant-safe) - ADR-0006 — Cloudflare Access (the per-site instantiation of it)
node_modules/emdash/src/loader.ts(~686–698, slug+locale-only query)apps/cms/scripts/emit-theme-css.mjs(build-baked theme, unchanged)- Memory:
per-site-worker-model,Scale and constraints