Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

Multisite and department delegation

One Spillway instance can host many sites, and each site can hand parts of itself to departments. This page explains how sites are resolved, what is shared and what is per site, how roles work per site and per section, and how a university would set it up. The API details are in api.md and the data model in architecture.md.

Sites

Every instance has a default site with the id and key default; it is the site the setup wizard creates and the one every request lands on when no other site matches. Admins (the global admin role, which holds the sites.manage permission) create more sites under Site settings → Sites, with:

  • a key (^[a-z][a-z0-9_-]{0,62}$, unique, used in URLs such as /api/sites/marketing and in ?__site=),
  • a name,
  • domains: host names such as www.example.edu (lowercase, no scheme, no port, no path), unique across the instance; the primary domain leads the list and the rest are aliases,
  • the default locale and the locales the site publishes in,
  • a status: active, or paused (visitors get a plain 503 page; the admin and the API keep working),
  • a brand: its own, or Inherit the brand of <site> (Shared brands).
# from the CLI
spillway site create --key engineering --name "School of Engineering" --domain engineering.example.edu
spillway site list

A new site starts with the default theme and SEO settings, an empty home page (the welcome page shows until one is set) and its own main and footer menus. Everything else is created in it afterwards, exactly as on the default site.

What is per site and what is shared

Per siteShared by the whole instance
Entities, revisions, media library, menus, redirects and the 404 log, collections, forms and submissions, alerts, audiences, experiments, analytics rollups, the search index, kit installs and the structure export, site settings (theme, SEO, integrations, privacy, locales), the brand (draft, versions, and the brand profile the AI tasks read)Content types, vocabularies and terms, shared blocks, users, API tokens, SSO providers, notification rules, modules, the AI settings

The brand profile (Brand → Voice) was instance-wide before migration 0041, which copied it to every existing site. A brand can still be shared between sites on purpose: see Shared brands.

Keys are unique per site: two sites may each have a contact form, a latest collection, a main menu or an /old-page redirect.

How a request picks its site

  1. The Host header, compared case-insensitively with the port stripped, against every site's primary domain and aliases.
  2. A signed-in user who can open another site may override it with ?__site=<id or key> on any URL (previews: /_preview/<id>?__site=marketing) or with the header X-Spillway-Site: <id or key> (what the admin's site switcher sends on every API call). An override the user cannot open is ignored.
  3. Otherwise the default site.

Run the instance behind a proxy that forwards the original Host header (the usual setting on nginx, Caddy, Fly.io and Kubernetes ingresses); a site with no domains is only reachable through the override.

The public renderer, the sitemap and robots files, llms.txt, feeds, the search page and API, redirects, alerts, personalization islands, the theme stylesheet and the analytics beacon are all per site. Cache keys carry the site id, and every rendered page carries the tag site:<id>; saving a site's settings evicts that site's pages only. (The type:<key> and list:<type> tags are still instance-wide, so publishing a page on one site also refreshes cached pages of the same type on the others; they refill on the next request.)

Roles per site

Roles still live on the user (roles) and apply everywhere: a global editor edits every site. In addition a user holds site roles (user_site_role): under Users → (user) → Sites, an admin assigns roles per site. The effective roles on a site are the global roles ∪ the site roles. A user may have no global roles at all (roles: []) and only site or section roles.

Who can open which sites (GET /api/me/sites, the admin's switcher):

  • a user with any global role can open every site (admins see paused ones too);
  • everyone else sees only the sites where they hold a site role or a section role.

A request on a site where the user holds no role gets an empty permission set, so the content API answers 403 there; entities of other sites simply do not exist from that site's point of view (404).

Sections: department delegation

A section is a part of a site owned by a department: a label, a key, a path prefix (/engineering) and optionally one menu of the site. Content belongs to the section whose prefix is the longest match for its path (/engineering owns /engineering and /engineering/labs, not /engineering-news). The section is assigned automatically on every save (and when a section is created or its prefix changes); an editor with site-wide rights may also pin a section or ask for re-assignment (sectionId: null).

Section roles (user_section_role) give a user a role inside a section. A section editor:

  • sees only the section's content in lists and the review queue, and may read, edit, submit, publish, unpublish, delete and schedule it;
  • may create content whose path falls under the prefix: give the slug a nested form (engineering/open-house → /engineering/open-house), or just a plain slug: when a section-only user's slug would land outside every section they hold, the first section's prefix is prepended automatically;
  • cannot move content out of the section (a slug change that leaves the prefix is refused), cannot touch content outside it (403), cannot read other sections' drafts;
  • may edit the section's menu (PUT /api/menus/<menuKey>: items and label, not the key), and nothing else under settings: no site settings, types, forms, redirects, users or other sections.

Site-wide, such a user gets only the ambient permissions GET /api/auth/me reports (content.view_drafts, content.create, content.submit_review, media.upload, ai.use); the per-section permissions come with sections in the same response. Every role assignment and every section change is audited (user.site_roles, user.section_roles, section.create|update|delete), and changing a user's roles ends their other sessions.

Nested paths: the built-in page type uses the pattern /{slug}, and a slug may now contain / to build a nested path (engineering/labs). Each segment is slugified on its own; a title-derived slug never contains a slash.

A university, step by step

The central web team runs www.example.edu; the School of Engineering gets its own site, and the Admissions and Athletics departments get sections of the main site.

  1. Sites. Site settings → Sites → New: key engineering, name "School of Engineering", domains engineering.example.edu. Install the higher-ed kit on it (Kits → Install, with the switcher on Engineering): the kit's collections, forms, menus and starter pages land on that site; its content types and vocabularies are shared with the main site.
  2. Site roles. Users → Dana → Sites: manager on Engineering. Dana now opens Engineering in the switcher, configures its theme, menus and forms, and never sees the main site. Users → Sam → Sites: editor on both sites.
  3. Sections on the main site. Site settings → Sections → New: key admissions, prefix /admissions, menu admissions (create the menu first); and athletics, prefix /athletics. Every existing page under /admissions/... joins the section at once.
  4. Section roles. Users → Priya → Sections: editor of Admissions. Priya signs in, sees only Admissions content, writes /admissions/visit-days, publishes it, and reorders the Admissions menu that the central team placed in the page layout. She cannot change the header, the home page or anything under /athletics.
  5. Review across departments. Give the central editors a global editor role: they see every section and every site in the review queue. Give a department head manager on a section when they should publish but not change the site shell.
  6. Delegated settings. Nothing in site settings is delegated to sections by design: the shell, the theme, redirects, forms and integrations stay with the site's managers. A department that needs its own shell gets its own site.
  7. One brand. Site settings → Sites → Engineering → Brand: Inherit the brand of Example University. On the main site, Brand shows Shared with 1 site: lock Color and Type, publish. Engineering now uses the university's colors and fonts (its Color and Type tabs are read-only) and may change its spacing or block styles. When the central team publishes a new primary color, Engineering follows at once.

Shared brands

A site can inherit another site's brand, part by part. The full workflow is in theming.md, Shared brands; in short:

  • Only an instance admin (sites.manage) sets or clears the inheritance (PUT /api/sites/:id { "brandParent": "<id or key>" | null }, or the Brand field of the site dialog). One level only: no chains.
  • The parent decides which parts (Identity, Color, Type, Space and shape, Styles, Voice) are locked, in its own Brand area, drafted and published with its brand. A child cannot change a locked part (403 brand_locked); it may override the others and reset them to inherited.
  • Publishing the parent recomputes every child's brand and purges their pages (site:<child>, theme, brand-guide; the CDN through the purge hook).
  • Deleting a parent site clears the inheritance on its children first: each keeps a copy of the brand it shows (the parent's logos are copied into its media library), so nothing visually changes, and each is audited as brand.inheritance_clear.

API summary

WhatEndpointPermission
SitesGET/POST /api/sites, GET/PUT/DELETE /api/sites/:id (brandParent sets the shared brand)sites.manage (admin)
Shared brand of the request's siteGET /api/brand/inheritance, POST /api/brand/draft/resetsite.settings
My sites (switcher)GET /api/me/sitesany user
Site settings of the request's siteGET/PUT /api/siteany user / site.settings
SectionsGET/POST /api/sections, GET/PUT/DELETE /api/sections/:idsite.settings for writes; reads for any user (section editors see theirs)
Roles per siteGET/PUT /api/users/:id/site-rolesusers.manage on every site touched
Roles per sectionGET/PUT /api/users/:id/section-rolesusers.manage on the section's site
Who am I on this siteGET /api/auth/me → siteId, permissions, sectionsany user

Every other endpoint is scoped to the request's site by the Host header or the X-Spillway-Site header.

Migration notes

Migration 0018_multisite adds the identity columns to site, a site_id column (backfilled with default) and an index to every per-site table, rebuilds the tables whose keys were unique instance-wide so they are unique per site (redirects, the 404 log, collections, forms and submissions, audiences, experiments, the analytics rollups) and the search index, and creates user_site_role, section and user_section_role. An existing instance keeps working unchanged: everything it had belongs to the default site, which keeps answering on every host until other sites claim their domains.

Migration 0041_brand_share makes the brand profile per site (the instance-wide profile is copied to every existing site, so no site's voice or banned words change) and adds the voice, the locks and the overrides to the brand draft and the published versions. The inheritance itself rides in each site's settings, so the site table is unchanged.

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?