RFC-0034: Multi-site installations: one Studio, many isolated sites
- Status: Accepted (2026-10-19, by Halil; ADR-0036). Open questions 1–5 decided as recommended. Phases A–F implemented (#200–#208); the notes in the text record where the implementation differs or defers.
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-18
- Related:
- RFC-0033 §7 and §9: “multi-site serving” is named there as the follow-up; phase F (the newsroom’s English edition) depends on it.
- Studio UX report (internal): when to choose languages in one site and when to choose separate sites.
- RFC-0018: one theme per site Worker (this RFC keeps the rule and moves it from the deployment to the site Worker).
- RFC-0005: per-site roles and the deployment Owner; RFC-0006 / RFC-0007: same-origin host checks.
- ADR-0012: site Worker entry and workerd prelude.
Correction (2026-10-19, phase A). Roles are deployment-wide today (RFC-0005 kept them so; its open question 10 recommends a
site_members (site_id, user_id, role)table). The per-site role isolation in §2 does not exist yet. It becomes phase A2: per-site roles before Studio’s “New site” (phase D). Until then, a member’s role applies to every site of the installation, and phase A’s site management is operator-only (deployment token,cloudhal site). Update: A2’s API and MCP enforcement shipped. A2b covers site assignment in Studio’s Members and Invite screens and per-site ability gating, and comes before D.
Summary
One self-hosted Cloudhal installation (one Studio, one D1, one R2 in one Cloudflare account) manages any number of sites. Each site is isolated: its own content, settings, members, keys, analytics and comments, exportable and deletable on its own. Sites are created from Studio or the CLI, starting empty or copied from another site as a language edition. They are served by one or more site Workers that pick the site from the hostname, so en.example.com and tr.example.com can be two independent sites run by the same team from one login. A site Worker carries one theme, so sites with different themes get different site Workers in the same installation. One-site installations keep working unchanged.
Motivation
- Today, every site is a whole installation.
cloudhal deploy --name XcreatesX-site,X-admin(Studio and API),X-dbandX-blobsfor exactly one site (CLOUDHAL_SITE_ID). A second site means a second Studio URL, a second login, inviting the team again, and a second deploy.- The data model is ready for more: every table is keyed by
site_id, Studio has a site list and switcher, andprojects/Site.projectIdexist. - But Studio says “sites are created with the CLI” (
SitesPage.tsx), and nothing adds a second site to an existing installation.
- The data model is ready for more: every table is keyed by
- Who needs it. All of them are self-hosted, open-source users; this RFC is not about a hosted Cloud.
- The newsroom with an English edition: RFC-0033 phase F.
- A team running
en.example.comandtr.example.comas independent sites. - An agency with ten client sites, each client seeing only their own.
- A journal portal with one site per journal, each with its own theme.
- Research (UX report §3) shows that separate sites suit divergent structure, separate teams, independent release rhythm, or separate domains. Their usual cost is management overhead. This RFC keeps the separation and removes the overhead.
Proposal
1. Terms
- Installation: one Studio (the admin Worker), one D1 database and one R2 bucket in one Cloudflare account. It is the management boundary: one login, one member list, one installation Owner (RFC-0005).
- Site: a publication inside an installation. It is the isolation boundary for content and people.
- Site Worker: a deployed renderer with one theme. It serves one or more sites of its installation, chosen by hostname.
- Project (exists, unused today): an optional grouping of sites inside an installation, for example the en and tr editions of one publication, or one client’s sites. It only affects how Studio groups and summarises sites; it is not an isolation boundary.
2. Isolation guarantees
A site in a multi-site installation is as independent as a site in its own installation, except for physical storage.
- Content: every row is scoped by
site_id, and every API route checks the caller’s role on that site (RFC-0005). Nothing is shared between sites unless someone links it on purpose. A cross-site translation group (RFC-0033 §7) is the only link, and it is opt-in. - People: roles are per site. A member sees only the sites they hold a role on. The installation Owner and admins can create sites and grant roles; they do not get content roles on every site automatically.
- Keys and agents: API keys and MCP sessions are issued per site, as today.
- Readers’ data: analytics, comments, commenter emails and search indexes are per site.
- Export:
cloudhal pull --site <id>andGET /api/admin/sites/:id/exportproduce the whole site: nodes, snapshots, assets, settings, and optionally comments. It can be imported into another installation withcloudhal push, so a site can leave with its data. - Delete:
DELETE /api/admin/sites/:id, owner-only, with a typed confirmation. It removes the site’s rows in guarded batches, deletes its R2 objects, its host rows and its cache, and is resumable if interrupted. - What is shared: the D1 database (500 MB on Workers Free, 10 GB on Paid), the R2 bucket, the installation’s Cloudflare account limits, and its uptime. A site that needs physical separation stays a separate installation (§8).
3. Creating sites
- API:
POST /api/admin/sites { name, slug, defaultLocale, locales?, projectId?, from? }, scopesite:create(installation Owner and admins).fromabsent creates an empty site seeded likecloudhal init(home page, system pages, starter settings).from: { siteId, copy: "structure" | "structure+translate" }is the edition flow (§6).
- CLI:
cloudhal site create --name … --locale en [--from <siteId>]andcloudhal site list. - Studio: Sites gets a “New site” action, which replaces today’s “created with the CLI” note. It asks for name, language and theme (one of the installation’s site Workers), and optionally “copy structure from…”.
- MCP: a
create_sitetool with the same scope. It never publishes.
4. Serving: site Workers and hostnames
- Host table (D1, written only by the API):
CREATE TABLE site_hosts (host TEXT PRIMARY KEY, -- lower case, no portsite_id TEXT NOT NULL,locale TEXT, -- a per-locale domain (Site.localization.domains)primary_host INTEGER NOT NULL DEFAULT 0);CREATE INDEX site_hosts_site ON site_hosts(site_id);
- It is derived from
Site.domainsandSite.localization.domainsand rewritten in the samebatch()as the site update, guarded on the site’s new content hash (hard rule 4). - Because
hostis the primary key, a hostname belongs to one site. The API answers409 host_takenbefore writing.
- It is derived from
- Site Worker registry (D1):
CREATE TABLE site_workers (name TEXT PRIMARY KEY, -- the Worker's script nameurl TEXT NOT NULL, -- where the admin Worker sends purgestheme TEXT NOT NULL, -- manifest name@versionsite_ids TEXT NOT NULL -- JSON array: the sites it may serve);
cloudhal deploywrites the Worker’s row.- The admin Worker sends a site’s purge to the Worker that serves it.
- Every site Worker of an installation shares the installation’s purge token.
- Deploying site Workers.
cloudhal.jsonkeepssiteId, the project’s own site thatdev,pushandpullwork on. It also takessites: ["site_…", …], every site the Worker serves, withsiteIdfirst. (Implementation note, phase C1: the two are not mutually exclusive, as first drafted.)- The first
cloudhal deploycreates the installation as today. - Another project directory (another theme, or the same theme for another group of sites) deploys only a site Worker into the existing installation with
"installation": "<name>"in itscloudhal.json. The Worker binds the installation’s D1 and R2 (databaseName/bucketNamealready exist in the deploy config) and registers insite_workers. - With
sites, the Worker getsCLOUDHAL_SITE_IDS. That list is its allow-list.
- The first
- Resolution in the Worker (
resolveSite(request), a new module next toruntime-workerd.ts):- Single-site mode:
CLOUDHAL_SITE_ID, exactly as today. - Multi-site mode, up to 1,000 hosts: the host rows of the allowed sites are loaded into isolate memory. They are reloaded when older than 60 s and on every purge call. Beyond 1,000 hosts, each host is looked up on first use and cached for 60 s.
- An unknown host, or one whose site is not in the allow-list, gets a plain 404, cached for 60 s. No other site’s content is ever served for it.
- The Worker’s
*.workers.devhostname serves the first site insites, markednoindex, so a fresh deploy has something to check.
- Single-site mode:
- Per-site state.
getResolver(siteId), media, search, feeds and the analytics and comments settings use the resolved id. The module-level caches become maps keyed bysiteId. Same-origin checks (RFC-0006 §5.2, RFC-0007 §6.2) read the site’s host rows.CLOUDHAL_SITE_HOSTSkeeps working in single-site mode. - Cache. The edge cache key already contains the host. Every response, 404s included, carries
cloudhal-site-<id>. In multi-site mode, purges use tags only, neverpurgeEverything, so one site’s publish never empties another site’s cache. - Locale domains (RFC-0033 §4) are host rows with
localeset; the host decides the locale before the path, as today.
5. Domains
cloudhal deploywrites one route (custom_domain: true) for every host of the sites a site Worker serves, read from the API.cloudhal domains syncupdates only the routes, so a domain added in Studio is live without a rebuild. Studio tells the user to run it, and shows each host’s state (pending, live, error). (Phase C note: deferred. A route change needs a Worker deploy, socloudhal deploycovers it for now.)- Phase C2 note: attached site Workers reach the admin Worker only through service bindings, because Workers cannot fetch each other’s public URLs. The installation’s deploy binds every registered Worker as
SITE_<n>and writesCLOUDHAL_SITE_ROUTES. The admin Worker routes each purge by thesiteIdin its body. - Cloudflare’s own limits apply per zone: 100 custom domains and 1,000 routes. Subdomains of one zone (
en.example.com,tr.example.com,client3.example.com) count against the same zone.
6. Language editions
A site created with from (§3):
- Copies: the theme settings, design and tokens; menus (labels copied, page items kept as structure); taxonomies and terms (labels for the new locale left empty); and the system pages and home page, as drafts in the new default locale.
- Never copies: articles, comments, analytics, members (the requester becomes the owner) or API keys.
- Assets are copied as rows and server-side R2 copies (
media_blobsis keyed by(site_id, sha256)), with no re-upload. - Translation groups: copied pages join cross-site groups with
source.siteId, startinglinked(RFC-0033 §4a), so structural changes arrive as drafts. Editors can switch a page toindependent. Withstructure+translate, agent translations are queued as drafts through the existing pipeline (save_translation, glossary, review). Nothing auto-publishes. - Phase E note:
- Implemented:
structuremode only (createEdition). Pages are copied as drafts in the source’s words, withmethod: "human"andlayout: "linked". - Not copied: terms and media. Pages that refer to them are left out and reported.
- Deferred:
structure+translate(queueing agent translations). Studio has no agent-translation request path yet; agents translate over MCP (save_translation).
- Implemented:
- Write discipline: a series of guarded
batch()calls. The new site’s id is chosen up front and returned at once; a retry resumes rather than duplicates. Progress shows in Studio.
7. Studio
- Sites screen: a “New site” action (§3), each site’s primary host and theme, and grouping by project when there is more than one.
- Switcher: the sidebar’s site switcher lists only the sites the user holds a role on, grouped by project.
- Project overview (
dashboardtemplate), shown when a project has more than one site: one row per site with publish state, pending approvals and translation coverage. Actions: “New site in this project”, “New edition”. - Translations: the coverage view gains a scope, “This site / All sites in the project”. Cross-site group members link to their own site’s editor.
- Language settings guide (UX report §4b): “Add a language here” next to “Create a separate site / edition”, with what each gives up. Languages in one site stay the recommended default.
8. Out of scope
- Sites in different Cloudflare accounts under one Studio. A Worker can bind only its own account’s D1 and R2, so this would need Studio to manage remote installations over HTTP. That is a later RFC. Until then, a site that must live in another account (for example a client’s own) is a separate installation.
- Separate storage per site under one Studio (a D1 per site): for data residency or very large sites. It would need per-site bindings and is a later RFC. Today’s answer is a separate installation.
- Hosted Cloud custom hostnames (Cloudflare for SaaS): the host table is designed to fit it, but it is not built here.
- Project-level roles: open question 1.
Invariants
- A hostname resolves to at most one site, enforced by the
site_hostsprimary key and checked by the API before writing. - A site Worker serves only the sites in its allow-list. Anything else is a 404, never a fallback to another site.
- Every API read and write is scoped to one site and checked against the caller’s role on it. Nothing crosses sites except opt-in translation-group links.
- In multi-site mode no purge empties another site’s cache: tags only.
- One-site installations (
siteId) behave exactly as before. The new paths are inactive withoutsites. - A site Worker serves only sites designed for its theme (
Site.theme.namematches its manifest). A mismatch blocks the deploy and is flagged in Studio. - Creating a site never copies readers’ data, members or credentials, and never publishes.
- A site can always be exported whole and deleted whole on its own.
Alternatives considered
| Option | Pros | Cons | Why not |
|---|---|---|---|
| One installation per site (status quo) | Full physical isolation | One Studio, login, team and deploy per site | The overhead this RFC removes; kept for sites that need physical separation |
| One D1 per site under one Studio | Physical data separation | A binding per site means a redeploy per new site; cross-site views need fan-out queries | Later RFC (§8); logical isolation covers the users above |
| Host map in Workers KV | Fast global reads | A new binding; eventually consistent; a second store to keep in sync with D1 | D1 is already bound; the table is tiny and cached with the same 60 s staleness |
| Host map baked into the build | No runtime lookup | Every domain change needs a rebuild | domains sync already needs only a route update |
| Several themes in one site Worker | One Worker for everything | Bundle size, test matrix (RFC-0018) | Several site Workers per installation give the same result |
| Workers for Platforms | Per-site code | Paid product, more parts | Not needed for open-source installations |
Capacity
What one installation can hold, from Cloudflare’s published limits (Workers and D1 limits pages, checked 2026-10-18):
| Limit | Workers Free | Workers Paid |
|---|---|---|
| Workers per account | 100 | 500 |
| D1 databases per account | 10 | 50,000 |
| D1 database size (all sites of an installation share it) | 500 MB | 10 GB |
| Custom domains per zone | 100 | 100 |
| Routes per zone | 1,000 | 1,000 |
- An installation needs 1 admin Worker plus 1 site Worker per theme in use, 1 D1 and 1 R2.
- Number of sites: not limited by Workers, because one site Worker serves any number of sites. In practice it is limited by:
- the shared database size: text content is small and media lives in R2, so tens of sites fit on Free and hundreds on Paid;
- 100 custom domains per zone;
- the account’s request quota.
- Today, for comparison: every site costs 2 Workers and 1 D1. Workers Free stops at 10 sites (the D1 limit) and Paid at 250 (the Worker limit).
Compatibility & migration
- Contracts: none.
Site.domains,Site.localization.domains,Site.projectIdandProjectexist.site_hostsandsite_workersare storage tables.schemaVersionis unchanged. - Migration
00xx_multi_site.ts: create both tables and backfill hosts from every site’s domains. A conflicting host is reported, the later one is skipped, and Studio shows it as a domain error. The installation’s existing site Worker is registered on its next deploy. - CLI:
sitesandinstallationare additive. An existingcloudhal.jsonwithsiteIdproduces exactly today’s config, and tests pin it. - Rollout, in order, one PR each:
- A: the tables, plus site create, export and delete in the API and CLI;
- A2: per-site roles (
site_members; owner and administrator stay installation-wide; an editor, author or contributor with site rows is limited to those sites), enforced inrouteGuardand the MCP policy, with a two-site isolation test in the shared API suite; - B: Worker resolution and per-site caches, tested on workerd with two sites and two hosts;
- C: CLI
sites,installation, routes,domains sync, and thesite_workerspurge; - D: Studio “New site”, grouped switcher, project overview;
- E: editions (
from); - F: cross-site coverage and the language-settings guide;
- then RFC-0033 phase F on top.
- Rollback: switch
cloudhal.jsonback tositeIdand redeploy. The tables are ignored in single-site mode.
Security & policy impact
- Isolation within an installation: invariants 1–4. The cache key includes the host, and purge tags are per site.
- New scopes:
site:create: installation Owner and admins; also used for editions.site:delete: Owner only, typed confirmation, audited.site:export: site owners.project:read: implied by any role on a site of the project, limited to those sites.
- Domain claims: adding a host needs
site:settings. Cloudflare’s custom-domain route proves control of the zone atdeploy/domains sync. - Site Workers attaching to an installation: a site Worker binds the installation’s D1 and R2 directly, so attaching one requires Cloudflare account access (wrangler login) and the installation’s purge token. These are the same powers as deploying the installation itself.
- MCP:
create_sitefollows the same rules: no publish, audited. - Preview tokens and cookies are per host, so a preview link for one site does not open another.
Open questions (recommendations given)
- Project-level roles (a “project admin” over all its sites)? Recommend: defer. Per-site roles, plus “invite to every site in this project” as a convenience.
- Should installation admins see every site’s content by default? Recommend: no. They manage sites and roles. They can grant themselves a role, and that grant is audited.
- Shared media across sites without copying? Recommend: copy for now. A project media library is a later RFC.
- Copied pages in an edition:
linkedorindependentby default? Recommend:linked(§6). - One analytics dataset per installation or per site? Recommend: per installation, filtered by
site_id, which rows already carry.
References
- Cloudflare Workers limits: https://developers.cloudflare.com/workers/platform/limits/
- Cloudflare D1 limits: https://developers.cloudflare.com/d1/platform/limits/
- Workers custom domains: https://developers.cloudflare.com/workers/configuration/routing/custom-domains/
- Workers Cache purge by tag: https://developers.cloudflare.com/workers/cache/purge/
- Workers KV consistency: https://developers.cloudflare.com/kv/concepts/how-kv-works/
- Storyblok multi-space orchestration: https://www.storyblok.com/docs/manuals/multi-space-orchestration
- Contentful localization strategies (space-level): https://www.contentful.com/help/localization/field-and-entry-localization/
- Google, cross-domain hreflang: https://developers.google.com/search/docs/specialty/international/localized-versions