ADR-0132: VayuDomains — multi-domain support (staged)
- Status: Accepted
- Date: 2026-07-12
- Relates to: ADR-0001 (SQLite-first), ADR-0033/0034 (migrations +
checksums), ADR-0048 (route registration), the
site.modebusiness-website topology, the in-process mail engine, the members/subscribers model
Context
VayuPress is a single sovereign binary that today serves one primary domain
(e.g. johal.in) with optional mail.<domain>, blog.<domain> and
talk.<domain> subdomains. The want is VayuDomains: let one install answer
on many independent hostnames, each choosing what it serves (blog, business
site, static bundle, or mail-only) and — eventually — carrying its own branded
mail, its own members, its own TLS certificate, and its own content, all with
strict isolation between domains.
This is a large, cross-cutting change. A recon pass across the routing, mail, content, member, TLS and governance subsystems surfaced several constraints that make an all-at-once implementation risky:
- TLS privilege model. The binary runs unprivileged on
:8080behind nginx + certbot. It cannot runcertbotor reload nginx itself. Let's Encrypt also caps a single certificate at 100 SANs, so N domains cannot share one cert — each needs its own (--cert-name <host>). Automated per-domain provisioning therefore requires a privileged root helper invoked out-of-process, not an in-binary call. - Member auth is globally unique. Subscribers/members are keyed by email across the whole install; they cannot currently be scoped per domain without a schema change and a careful data migration.
- Content scoping is a multi-table change.
articles.slugis globally UNIQUE and the slug is a shared key acrossarticles,article_access,article_sources,article_versions,scheduled_posts,collectionsandcomments. Per-domain content requires threading a domain key through all of them — a 7-table, PK-constrained change. - Mail isolation. The mail engine resolves a mailbox to a Maildir from the primary domain; a naïve second domain would read the primary's mail. Branded per-domain mail needs the Maildir/account resolution to be domain-aware.
Doing all of this in one release would be a high-blast-radius change to the exact subsystems that carry the product's guarantees (isolation, no-plaintext mail, immutable migrations). The primary domain must stay byte-identical throughout.
Decision
Ship VayuDomains in stages, each independently shippable and reversible, with the registry as the stable foundation the later stages build on. The primary domain is always described by the registry rather than changed by it.
Stage 1 — Foundation (this release)
- Migration 059 adds a
domainstable:id,host(unique, normalised),site_type,mail_enabled,tls_state,config_json,is_primary,status, timestamps. One statement per physical line per the migration runner's contract (ADR-0033/0034); the checksum is immutable once shipped. internal/domainis the registry service: an in-process, TTL-cached, host-keyed store withResolve,Primary,List,Create,Update,SetStatus,SetTLSState,Delete, andEnsurePrimary.- Go-side seed. At startup
EnsurePrimary(config.Cfg.Domain, site.mode)idempotently inserts (or repairs) exactly one primary row, withtls_state = 'primary'(its cert is the existing certbot cert, managed outside the registry). The primary row describes the existing install, sojohal.inis byte-identical. The migration cannot read env, so the seed is Go-side, not SQL. - Host-resolution middleware annotates every request with the resolved domain and does nothing else. An unknown or disabled host falls back to the primary, preserving today's "answer on any host" behaviour. No handler yet branches on the resolved domain, so behaviour is unchanged.
/os/domainsadmin page lists domains and manages secondary ones (add / enable / disable / remove), with the primary shown read-only. The page states plainly that content/mail/member scoping arrive in later stages so an operator is never surprised by what a new domain does and does not yet serve.
The primary domain is protected: it cannot be disabled, deleted, or edited
from this page (its site_type tracks the global site.mode; disabling it
would take the install offline).
Stage 2 — Content scoping
Stage 2a (shipped): content-ownership foundation. Migration 060 adds an
additive domain_id column to articles (default '' = the primary /
unassigned bucket), so an existing install is byte-identical — the primary
domain still serves exactly its historic rows. The repository and article
service gain domain-scoped reads (ListScoped / GetScoped, with db.ScopeAll
disabling the filter for the admin/JSON path), plus SetDomain and
CountsByDomain. VayuOS → Domains shows a per-domain post count and lets an
operator assign a post to a domain by slug. The public render path is
deliberately not rewired yet, so behaviour is unchanged and provably safe.
Stage 2b (shipped): per-domain public homepage + article pages. The public
homepage feed and article pages now serve only the active domain's content,
keyed off the Stage 1 host-resolution middleware. Safety was the design centre:
the per-domain code paths run only when a secondary domain is registered
(multiDomain), so a plain single-domain install takes its original route with
zero added work and is provably byte-identical (proven by the unchanged home/
article tests). When multi-domain is active, the homepage query is scoped by
domain_id and cached to a per-domain file (home/d_<id>/index.html; the
primary keeps home/index.html), and the article page gates on ownership — a
post is reachable only on its owning domain (slugs are globally unique, so this
is an exact one-owner lookup). Reassigning a post lazily purges the caches so
every domain re-renders on next request.
Stage 2c (shipped): per-domain taxonomy, feeds, sitemap and search. The
remaining public discovery surfaces are now scoped to the active domain, on the
same multiDomain gate as Stage 2b — a single-domain install keeps the original
global code path and artefacts, byte-identical:
- Tag index + tag pages.
/tagscounts only the active domain's published posts, and/tags/<tag>lists only that domain's matches (cached per domain attags/d_<id>/…). A tag with no published post on the domain 404s as before. - Sitemap + feed + robots.
/sitemap.xml,/feed.xmland/robots.txtare served from per-domain artefacts (sitemap_d_<id>.xml,feed_d_<id>.xml,robots_d_<id>.txt) that list only the domain's posts/tags and carry the domain's own host, so no domain ever advertises a URL that would 404 under the Stage 2b ownership gate. They are generated lazily on serve within a short freshness window rather than wired into the per-postCachePurgefan-out, keeping the change self-contained; the historic global artefacts remain the single-domain path. - Search. The VayuFind engine stays a single, domain-blind index (no engine
fork). The server endpoint (
/api/v1/search) over-fetches and post-filters hits by ownership; the downloadable client index (/api/search-index.json) that hydrates the instant-search modal is filtered to the domain's slugs and memoised per domain, rebuilt when the engine snapshot version changes. Reassigning a post between domains touches no indexed field, so that path explicitly drops the per-domain index memo.
The JSON/GraphQL query API (gqlResolver.Search/Tags/Articles) remains a
global surface, consistent with the db.ScopeAll admin/API convention — it is a
query interface, not a per-host rendered site.
Per-domain branding (shipped, v3.13.11): each secondary domain can now
present as its own site — site name, tagline, meta description, light/dark accent
colours and browser theme-colour — stored in the registry's config_json
(Domain.Brand() / Registry.SetBrand, no schema change) and overlaid onto the
site settings when that domain's homepage, articles and /theme.css render. The
overlay is reached only when a secondary domain is registered and carries a
brand (App.brandForRequest); in every other case the original renderers are
called verbatim, so the primary is byte-identical (same bytes, same ETag) and a
single-domain install pays only the cached HasSecondaries bool. Blank fields
inherit the primary; colour fields are hex-validated before they can reach CSS or
the theme-colour meta tag. Still deferred: per-domain canonical/OG host on
the rendered pages (articles still emit config.Cfg.Domain in canonical/OG URLs);
the UNIQUE(domain_id, slug) relaxation (below) remains a later sub-stage.
Constraint carried forward: articles.slug is globally UNIQUE via an inline
(un-droppable) constraint, so two domains cannot yet share a slug. Relaxing that
to UNIQUE(domain_id, slug) needs an articles-table rebuild and is a later
sub-stage.
Stage 3 — Mail branding + isolation
Make mailbox/Maildir resolution domain-aware (constraint #4) so mail_enabled
domains carry their own branded mail without reading the primary's Maildir. The
recon that opened this stage found the on-disk Maildir layer is already
domain-partitioned (<base>/maildir/<domain>/<user>/…); every resolution path
simply collapses the domain to the primary cfg.Domain. Because existing storage
already sits under <primaryDomain>/<user>, threading the real domain leaves the
primary byte-identical — but doing so touches the acceptance gate, the IMAP/POP3
login path and the whole engine mailbox API at once, which is exactly the
high-blast-radius change on the mail guarantees this ADR exists to avoid. The mail
subsystem is therefore staged like content (2a → 2b → 2c):
Stage 3a (shipped): mail-domain foundation. The VayuOS Domains page now
reports, per domain, whether it carries mail and how many mailboxes it holds
(AccountStore.CountsByHost, a read-only derivation — mailboxes are keyed by full
address, so the host is derived, not stored). The delivery, authentication and
Maildir-resolution paths are deliberately untouched, so the primary domain's
mail is provably byte-identical (proven by the unchanged mail-engine tests). This
gives operators visibility into the rollout without putting a live mail server at
risk.
Stage 3b (shipped): per-domain receive + read isolation. A mail_enabled
secondary domain now receives external mail into its own isolated Maildir and its
users read only that mailbox over IMAP/POP3:
- Acceptance. A single predicate —
Config.AcceptsMailDomain(primary viacfg.Domain, secondaries via an injectedMailAcceptsresolver backed by the registry) — gates the SMTP receive server (recipientAccepted), the engine's local-recipient check and the bridge'sIsLocalRecipient.MailAcceptsis nil in every single-domain install and test, so the predicate collapses to the historic primary-only check — byte-identical. - Delivery already carried the parsed recipient domain to
maildir.Deliver(domain, …); opening acceptance is enough for a secondary's mail to land in<base>/maildir/<secondary>/<user>/…. - Read. IMAP and POP3 sessions record the authenticated login's owning domain
(
mailboxDomainFor, which returns the primary unless a genuinely different — secondary — login domain is presented) and key every Maildir operation on it instead ofcfg.Domain. A bare local part orlocal@primarylogin stays on the primary, so the primary path is unchanged. Auth was already domain-correct (AuthUseruses the full login address). - Provisioning. The Accounts admin page lets an operator create a mailbox on a
mail_enabledsecondary (a domain picker appears only when one exists); the address, Maildir and PGP key are provisioned under that domain. - Webmail stays primary. CMS-user mailboxes are always primary (team
assignment hardcodes the primary domain), and the webmail
userparam is a local part resolving tocfg.Domain, so webmail never reads a secondary Maildir — no cross-domain leak. Full webmail access to secondary mailboxes rides with Stage 3c.
Covered by explicit cross-domain isolation tests (bob@example.com and
bob@shop.example are distinct stored messages), acceptance-predicate tests and
the login→domain resolution table. safeSegment already neutralises hostile
domain/username path segments, so feeding the real (attacker-influenceable)
recipient domain into the Maildir path adds no traversal surface.
Stage 3c (shipped): per-domain outbound + autoconfig. A mail_enabled
secondary domain now sends DKIM-aligned, deliverable mail and its clients
auto-configure with their own address:
- Per-domain DKIM.
Engine.dkimFor(domain)returns a signer that reuses the selector's shared private key but carries the sender's domain in the signature'sd=tag, so a secondary's outbound validates against the DKIM record published at<selector>._domainkey.<secondary>. The primary send path uses the signer loaded at start unchanged (byte-identical). Every send/compose/autoresponder sign site selects by the From domain, and theMessage-IDand Sent-folder copy now carry the sender's own domain too (so a secondary sender's Sent copy stays in its isolated Maildir). - Per-domain autoconfig.
/.well-known/vayumail/autoconfig.jsonand the Thunderbird XML resolve the account domain from the request Host (amail_enabledsecondary, else primary), while keeping the advertised server host the primary mail host — its cert is valid and it serves every domain's mailboxes, whose users log in with their own address. No per-secondary cert is required for clients to connect today.
Operator note (DNS). Because the DKIM key is shared across domains, a
secondary domain publishes the same <selector>._domainkey TXT record as the
primary, plus its own MX (→ the primary mail host), SPF and DMARC.
Stage 3d (in progress): per-domain DNS panel (shipped). VayuOS → mail now
renders, below the primary's records, a DNS-records-to-publish card for each
mail_enabled secondary (Engine.PlannedRecordsForDomain): its MX points at the
primary mail host, its SPF/DMARC are scoped to itself, and — because the DKIM key
is shared — it publishes the same key value at its own <selector>._domainkey
record. This closes the operability gap: an operator can now set a secondary
domain's mail up end to end (register → enable mail → create mailbox → send
DKIM-signed → publish the shown DNS).
Stage 3d webmail access (shipped). A CMS user can be assigned a mailbox on a
mail_enabled secondary domain (team assignment now takes an optional domain,
validated by acceptsSecondaryMailDomain), and that user reads/searches/acts on
their mail in the built-in webmail. The engine read API became domain-aware
(Engine.mailboxKey accepts a bare local part → primary, byte-identical, or a full
address → that domain), and the webmail handlers resolve the mailbox key
server-side from the authenticated identity (ownMailboxKey) — never from the
XSS-sanitised user param, so the domain carries no injection surface and the
primary path is byte-identical (the key collapses to the local part on the primary
domain). Still deferred: per-domain webmail/console branding from the registry's
reserved config_json (its meaning — public site identity vs console chrome — will
be pinned before building). Per-secondary TLS certs remain P4.
Performance note. Every per-domain path is gated behind
Registry.HasSecondaries (a precomputed cached bool — no map scan, no per-request
DB query) or the primary-first Config.AcceptsMailDomain check (the registry
resolver is consulted only for non-primary recipients), so a single-domain install
— the overwhelming common case — carries zero added work on the blog or VayuOS
hot paths.
Stage 4 — Member isolation
Scope subscribers/members per domain (constraint #2).
Stage 4a (shipped): member domain attribution. Migration 061 adds an additive
domain_id column to members (default '' = primary), and a new signup is
attributed to the active domain (UpsertScoped, gated on multiDomain so a
single-domain install is byte-identical). Because a member is identified by the
globally-UNIQUE email and login is by email, the read/auth path is deliberately
unchanged — Get/login find a member by email regardless of domain, so
domain_id is attribution + reporting, not a login filter, and no existing member
flow changes. VayuOS → Domains shows a per-domain member count
(members.CountsByDomain) beside the article and mailbox counts.
Stage 4b (deferred): relaxing the global UNIQUE(email) to
UNIQUE(domain_id, email) — so the same address can be an independent member of
two domains — needs a members-table rebuild (the same inline-constraint hazard as
articles.slug). The signup paths without a request context — Stripe checkout
fulfilment and the Stripe webhook — attribute to the primary ('') until the
owning domain is carried on the order/customer; per-domain magic-link hostnames and
per-domain newsletter subscribers ride here too.
P4 — TLS + nginx automation (shipped)
A privileged root helper, scripts/setup-vayudomain.sh (in the shape of the
existing setup-talk-subdomain.sh), provisions each registered secondary domain's
TLS + nginx out-of-process, since the unprivileged server can never run certbot or
reload nginx itself. For every secondary host it obtains its own Let's Encrypt
certificate (certbot --cert-name <host>, a separate lineage per domain — the
primary's cert is never expanded, so the 100-SAN cap is structurally impossible to
hit) — including www.<host> and, for a mail_enabled domain, mail.<host> so
its mail clients get a valid cert — then writes a reverse-proxy vhost to the origin
and records the outcome back into the registry's tls_state.
The binary exposes the read/notify surface the helper drives from — vayupress domains (list / hosts [--mail] / set-tls <host> <state>) — so the binary
only records state while the helper acts on it. deploy-vayupress.sh and
update-vayupress.sh call the helper (idempotent, non-fatal): a domain whose DNS
is not yet pointed here is skipped cleanly, so it is safe on every deploy/update.
P5 — manual sync gate (shipped)
P4 provisioned every registered secondary on every deploy/update run. That made "Add a domain" quietly consequential: registering a hostname was enough for the next update to obtain certificates and publish an nginx vhost for it. P5 separates registering from provisioning with an explicit, per-domain operator approval:
- Migration 064 adds
domains.sync_state('approved'|'hold'). Existing rows are backfilled toapproved(they were already provisioned; updates keep maintaining them), whileRegistry.Createinserts new secondaries ashold. vayupress domains hosts— the helper's work list — emits only sync-approved secondaries. New verbsdomains sync <host>/domains hold <host>flip the gate from the terminal;hosts --holdlists what is parked andhosts --alllists every active secondary for diagnostics.- VayuOS → Domains shows the gate as a Sync column (
Synced/Manual hold) with a per-row Sync now / Pause sync action (POST /os/api/domains/{id}/sync, CSRF-protected like its siblings). The page copy states plainly that approving only queues the domain for the helper's next run — the unprivileged binary still never touches certbot or nginx. setup-vayudomain.shtreats explicit host arguments as operator intent:sudo bash scripts/setup-vayudomain.sh shop.examplerecords the approval (domains sync) and provisions immediately. Runs without arguments log which domains were skipped on hold, so a deploy/update log always explains itself.
The primary is untouched by the gate (its certificate predates the registry), and a domain already provisioned under P4 behaves exactly as before — the gate changes the default for new registrations only.
Consequences
- Safe foundation. Stage 1 adds a registry and a pass-through middleware with zero behavioural change to the primary domain — a low-risk base that the higher-risk stages depend on but do not yet trigger.
- Honest UI. The admin page advertises the rollout state, so "add a domain" never over-promises isolation or mail that a later stage delivers.
- No cert foot-guns. The per-domain-cert + root-helper decision is recorded now, so P4 does not accidentally try to certbot from the unprivileged process or blow the 100-SAN cap.
- Immutability respected. Each stage is a new forward migration; migration 059 is never edited after shipping (ADR-0034).
- The deferred stages are decisions the operator drives release by release, not surprises — this ADR is the roadmap and the hazard register for them.