Skip to content

0028 — Per-Portal Subdomain Addressing & TLS ​

Status: Accepted (2026-06-21)

Date: 2026-06-21

ADO work item: Epic AB#3074 (Platform build); Story AB#3675 (domain naming / HCS tenant / Cloudflare); Feature AB#3079 (per-portal subdomains)

Deciders: Kristopher Turner (platform owner)

Amends: ADR 0026 — Production Domain and DNS Strategy, ADR 0008 — Platform Composition


Context ​

ADR 0026 established heritageva.app as the production domain and recorded two DNS records: the apex CNAME (web app) and api. (Container Apps). It stated that Azure-managed certificates handle TLS for all subdomains and assumed the SWA custom-domain flow would cover whatever portal subdomains were needed later.

ADR 0008 described the core web shell as a single apps/web with "navigation, auth-gated routing, layout, and account/profile screens" — implying one shell, one hostname. Neither ADR resolved how the platform's multiple portals (homeschool, marketplace, family, calendar, etc.) are addressed on the web.

Two facts discovered during Platform build invalidate the ADR 0026 subdomain TLS assumption:

  1. Azure SWA Free and Standard tiers do not support wildcard certificates. SWA Free permits up to 2 custom domains; SWA Standard permits 5–6. Neither issues wildcard (*.heritageva.app) managed certificates. SWA only accepts Azure-managed certs — BYO/uploaded certificates (e.g. a Let's Encrypt wildcard) are not supported on either tier.
  2. Alternative wildcard solutions are either unavailable or cost-prohibitive. Let's Encrypt and ZeroSSL issue ACME wildcard certificates, but they cannot be uploaded to SWA. Azure Front Door supports wildcard certs but costs ~$35/month — rejected for a ~200-member ministry. SWA Standard tier's extra custom-domain slots still produce only per-name managed certs, not wildcards.

Additionally, two overview documents (ministry-leadership-overview.md, executive_summary.md) referenced portal subdomains under the abandoned base domain heritage-community-hub.com, which is not registered or in use. This ADR corrects the record.

Decision ​

The platform exposes each major portal on its own subdomain of heritageva.app. All portal subdomains are Cloudflare-proxied CNAMEs pointing at the single Azure SWA. Cloudflare Universal SSL (free, apex + first-level wildcard *.heritageva.app) terminates TLS at the edge. A Cloudflare Origin Rule rewrites the Host header to the SWA's default hostname before forwarding, so SWA serves regardless of which subdomain was requested. The web SPA reads window.location.hostname and mounts the matching portal module.

Subdomain map ​

HostnamePortalTLS / routing
heritageva.app (apex)App shell — sign-in / register / homeSWA custom domain (Azure-managed cert) or CF-proxied
www.heritageva.appCloudflare redirect rule → https://heritageva.app (301)
api.heritageva.appAPIAzure Container Apps managed cert — unchanged from ADR 0026
profile.heritageva.appMember profileCF-proxied → SWA
family.heritageva.appFamily portalCF-proxied → SWA
homeschool.heritageva.appHomeschool Education PortalCF-proxied → SWA
marketplace.heritageva.appCommunity MarketplaceCF-proxied → SWA
pony-express.heritageva.appPony Express delivery networkCF-proxied → SWA
listen.heritageva.appSermons & MusicCF-proxied → SWA
calendar.heritageva.appCalendar / eventsCF-proxied → SWA
groups.heritageva.appSmall Groups & MinistriesCF-proxied → SWA
members.heritageva.appMember directoryCF-proxied → SWA
rideshare.heritageva.appRide share & travelCF-proxied → SWA
communities.heritageva.appSister CommunitiesCF-proxied → SWA
announcements.heritageva.appAnnouncementsCF-proxied → SWA

The list is derived from apps/web/src/pages/. Detail pages (e.g. a single sermon, a single listing) remain as paths within the portal's subdomain rather than additional subdomains.

Cloudflare configuration (executed at deploy gate AB#3106) ​

These changes are scripted/runbooked now but executed as live external changes at the deploy gate:

  1. Universal SSL — verify Cloudflare Universal SSL is active for heritageva.app (it covers heritageva.app + *.heritageva.app automatically for any domain on Cloudflare).
  2. CNAME records — one per portal subdomain, Cloudflare-proxied (orange cloud):
    CNAME  profile          <swaDefaultHostname>   Proxied
    CNAME  family           <swaDefaultHostname>   Proxied
    CNAME  homeschool       <swaDefaultHostname>   Proxied
    CNAME  marketplace      <swaDefaultHostname>   Proxied
    CNAME  pony-express     <swaDefaultHostname>   Proxied
    CNAME  listen           <swaDefaultHostname>   Proxied
    CNAME  calendar         <swaDefaultHostname>   Proxied
    CNAME  groups           <swaDefaultHostname>   Proxied
    CNAME  members          <swaDefaultHostname>   Proxied
    CNAME  rideshare        <swaDefaultHostname>   Proxied
    CNAME  communities      <swaDefaultHostname>   Proxied
    CNAME  announcements    <swaDefaultHostname>   Proxied
    CNAME  www              <swaDefaultHostname>   Proxied (redirect rule handles 301)
  3. Origin Rule — match http.host matches "*.heritageva.app" → set Host header to <swaDefaultHostname>. This is what makes SWA accept requests for portal subdomains it has not been individually configured with.
  4. www redirect rulehttp.host eq "www.heritageva.app" → redirect to https://heritageva.app (301, preserve path).
  5. Apex + api records — unchanged from ADR 0026; api record stays DNS-only (grey cloud) per Azure Container Apps managed-cert validation requirement.

SWA Bicep stays apex-only &ZeroWidthSpace;

Because Cloudflare handles subdomain TLS and proxies all portal subdomains to the SWA default hostname, the SWA Bicep module (infrastructure/modules/swa.bicep) requires no multi-domain changes. It is parameterised for the apex (customDomain=heritageva.app). The comment in the module is updated to document this explicitly.

SPA hostname routing &ZeroWidthSpace;

apps/web/src/lib/portal-router.ts exports a resolvePortal(hostname) function that reads window.location.hostname, strips .heritageva.app, and returns a PortalKey. App.tsx calls this on mount and renders the matching portal module. Unrecognized hostnames (including localhost) fall back to the full-app shell.

Amendments to prior ADRs &ZeroWidthSpace;

ADR 0026 (Production Domain and DNS Strategy) &ZeroWidthSpace;

ADR 0026's "SSL/TLS" section stated: "Azure SWA built-in managed certificate; issued and renewed automatically once the CNAME and TXT validation records are in place." This was correct for the apex domain but did not address subdomains. The claim that "Azure-managed certificates handle TLS for all subdomains" is superseded by this ADR: Cloudflare Universal SSL, not Azure-managed certificates, provides TLS for all portal subdomains. The apex cert from Azure SWA (or Cloudflare Universal SSL, whichever is active) is unaffected. ADR 0026 is amended by this ADR.

ADR 0008 (Platform Composition) &ZeroWidthSpace;

ADR 0008's description of the core web shell as "Feature web UIs mount inside this shell" implied a single hostname. This ADR reconciles, not contradicts, that: the single apps/web shell still serves all portals; the subdomain determines which portal section mounts inside the same shell. ADR 0008 is amended by this ADR to reflect that the web shell is hostname-routed.

Alternatives considered &ZeroWidthSpace;

OptionCost / moWildcard?Custom domainsWhy not chosen
Cloudflare Universal SSL + CF proxy (chosen)FreeYes (*.heritageva.app)Unlimited via CF proxy— chosen
Azure SWA Free managed cert$0No22-domain cap; no wildcard
Azure SWA Standard managed cert~$9No5–6Per-name certs only; still no wildcard; adds recurring cost
Let's Encrypt / ZeroSSL wildcardFreeYesn/aCannot upload to SWA; would require a self-run TLS-terminating proxy (nginx/Caddy) in front of SWA — adds operational complexity and another Azure resource
Azure Front Door + wildcard cert~$35YesUnlimitedCost-prohibitive for a ~200-member ministry
One SWA domain per portal (no wildcard)$0 + $9/portal/moNoMultiple SWA instances; duplicated CI/CD; no shared auth session; cost multiplies with portal count

Consequences &ZeroWidthSpace;

Positive &ZeroWidthSpace;

  • Free wildcard TLS with no per-subdomain configuration or cert renewal — Cloudflare Universal SSL covers all current and future portal subdomains automatically.
  • Single SWA instance — no multi-instance operational burden; one deploy pipeline; shared Clerk session works across subdomains (same root domain, Clerk domain config covers it).
  • Clean portal URLscalendar.heritageva.app, listen.heritageva.app, etc., are memorable and meaningful for a ~200-member community.
  • SWA Bicep unchanged — no Azure-side changes required for subdomain routing; Cloudflare handles it entirely.

Negative / trade-offs &ZeroWidthSpace;

  • Cloudflare becomes a hard dependency in the TLS path for portal subdomains. If Cloudflare has an outage, subdomain TLS fails. Mitigation: the apex (heritageva.app) retains an Azure-managed cert as a fallback entry point; monitor Cloudflare status as part of the standard observability runbook.
  • The Origin Rule approach means SWA cannot enforce strict Host-header validation per subdomain (all portals look the same to SWA). Auth is enforced by Clerk server-side, not by hostname, so this is an acceptable trade-off.

Risks &ZeroWidthSpace;

  • Shared-session cookie scope — Clerk's session cookie must be scoped to .heritageva.app (root domain) for the session to work across subdomains. Verify Clerk's domain configuration at deploy time.
  • CORS — the API at api.heritageva.app must include all portal subdomains in its allowed origins. This is a deploy-time config change; the API's allowed origins list must enumerate or wildcard-match *.heritageva.app.
  • Cloudflare cache — portal HTML served through Cloudflare's CDN must set Cache-Control: no-store for authenticated routes to prevent cross-user cache poisoning. SWA's default headers are acceptable; review at deploy time.

References &ZeroWidthSpace;

Heritage Community Hub — Internal. Access restricted via Cloudflare Access + Entra ID.