SkillPixel LMS — Tenant Domain Provisioning

How a tenant hostname comes online: managed subdomains now, customer custom domains later — epic SP-371

Written 2026-08-21 · status: proposal · decisions marked · rev 2 (apex support, 2026-08-21)

Two kinds of tenant hostname, two different pipelines.

Managed subdomains (<slug>.skillpixel.vn) are ours end to end. Custom domains (tenantc.com / learn.tenantc.com) belong to the customer. They need different tooling, so we ship them in different phases.

Backend host routing is already done and beta-verified. What is missing is the edge work: DNS record, DigitalOcean domain, certificate, Firebase authorized domain. Today that is manual. This page proposes how to automate it.

Legend FACTtrue today, verified DECIDEDalready agreed PROPOSALnot yet agreed DECISION NEEDEDneeds a call from Khang REJECTEDconsidered, not doing

0TL;DR

Managed subdomains

<slug>.skillpixel.vn

  • Keep per-hostname registration on DigitalOcean + Cloudflare. DECIDED 2026-08-02 Proven on beta and prod. Cost $0.
  • Automate it now as a GitHub Actions workflow_dispatch workflow. PROPOSAL
  • Move it behind the platform API later, not now.
  • Do not pursue the DO wildcard. REJECTED
Custom domains

tenantc.com / learn.tenantc.com

  • Use Cloudflare for SaaS custom hostnames with lms-prod as fallback origin. PROPOSAL
  • Gate activation on verification (verified_at) and on the Firebase authorized-domain cap — warn at 75, block at 90.
  • Apex (tenantc.com) and subdomains (learn.tenantc.com) both supported. Apex needs the customer's DNS to support ALIAS/CNAME flattening, or a www fallback — see §3.
  • Start only after managed subdomains have run on prod for a few weeks.

1Where we are today FACT

Everything in this section is true right now. No proposals here.

Edge and DNS

Prodskillpixel.vn is one DO App Platform app, lms-prod. Ingress: /api + /health → backend, / → static frontend. The frontend calls the API same-origin.
BetaSplit-host: DO static site lms-beta-frontend at beta.skillpixel.vn plus Cloud Run backend lms-backend-beta. Because of the split, beta uses the X-Tenant-Host hint header.
DNSZone skillpixel.vn on Cloudflare (NS cartman.ns.cloudflare.com / noor.ns.cloudflare.com).
PolicyALL DNS stays on Cloudflare. No NS delegation to DigitalOcean.

Backend routing DONE · BETA-VERIFIED 2026-08-20

  • Host resolves to a tenant only when it matches an ACTIVE tenant_domain of an ACTIVE tenant.
  • Platform hostnames (skillpixel.vn, www.skillpixel.vn, beta.skillpixel.vn) short-circuit to the default tenant.
  • Unknown hosts return 400/404. They never fall through to the default tenant.
  • Write path canonicalizes (IDNA/punycode, lowercase) and rejects reserved labels — admin api app assets beta dev mail staging static www — plus the platform apex skillpixel.vn and localhost. Customer apex domains such as tenantc.com are accepted (verified 2026-08-21 against normalize_host_header / is_reserved_hostname). It also refuses PLATFORM type (SP-606).
  • Activate/deactivate invalidates routing caches across all instances within about 1 second, via a Redis generation key (SP-607).
Gotcha: tenants created via POST /api/v1/platform/tenants start as PROVISIONED. Domains only route once the tenant is ACTIVE.

Domain rows and DO app domains right now

WhereHostnames
Beta tenant_domainsbeta.skillpixel.vn (PLATFORM); historyclass.beta.skillpixel.vn, tenanta.beta.skillpixel.vn (MANAGED_SUBDOMAIN)
Prod DO appskillpixel.vn (PRIMARY), canary.skillpixel.vn (ALIAS)
Beta DO apptenanta, nosuch, historyclass — all .beta.skillpixel.vn, all ALIAS

Fields that already exist on tenant_domains: status (PENDING/ACTIVE/DISABLED), domain_type (PLATFORM/MANAGED_SUBDOMAIN/CUSTOM), is_primary, verified_at.

What is manual today

From docs/specs/001-multi-tenant/edge-routing.md. Five steps per hostname, all by hand.

  1. DigitalOcean: add <host> as an ALIAS domain on the app via doctl apps update. Exact domains auto-validate over CNAME; the Let's Encrypt cert lands in about 2 minutes.
  2. Cloudflare: CNAME <host> → the app's default ingress hostname, DNS only (grey cloud).
  3. Prod only, optional: flip to Proxied once DO shows ACTIVE. Universal SSL covers single-level *.skillpixel.vn. Beta must stay DNS-only — Universal SSL does not cover the nested wildcard *.beta.skillpixel.vn.
  4. Firebase: add <host> to authorized domains. No wildcard support.
  5. Our API: PATCH the tenant domain status to ACTIVE.

2Managed subdomains — recommended path PROPOSAL

Same five manual steps as today, run by a workflow instead of a person.

platform-adminadd domain (PENDING)
GitHub Actionsprovision-tenant-domain
CloudflareCNAME, DNS only
DigitalOceanALIAS domain + cert
workflowpoll until ACTIVE
prod onlyproxied flip
Firebaseauthorized domain
our APIdomain ACTIVE

Three options

A — GitHub Actions workflow RECOMMENDED NOW

provision-tenant-domain.yml, workflow_dispatch, inputs hostname, environment (beta|prod), action (add|remove).

  • Secrets are already in Actions — DO token and Firebase service account. The Cloudflare token is the only new one.
  • Every run is audited in the Actions log.
  • No backend change. Can ship in a day.
  • Later it can be triggered from platform-admin through the GitHub API, with no rewrite.

B — Backend side effect END STATE · PHASE 2+/5

Provisioning runs as a side effect of update_domain(status=ACTIVE).

  • Needs a real state machine: PENDING → DNS_CREATED → DO_REGISTERED → ACTIVE.
  • Needs retries and async polling — the DO cert takes minutes, so it cannot live in a request.
  • Tie it to verified_at when we get there.
  • Not a prerequisite for SP-476.

C — Local script SKIP

Fast to write, but it puts prod secrets on laptops and leaves no audit trail. Only worth it as a stepping stone to A, and even then A is about the same effort.

Workflow steps

  1. Validate. Hostname must be <label>.skillpixel.vn (prod) or <label>.beta.skillpixel.vn (beta). The label must pass the same rules as SP-606: lowercase, IDNA-valid, not reserved. Refuse anything else.
  2. Cloudflare. POST /client/v4/zones/{zone_id}/dns_records with type CNAME, name <host>, content = the app default ingress (lms-prod-xxxx.ondigitalocean.app or the beta equivalent — read it with doctl apps get --format DefaultIngress), proxied:false. Idempotent: look up an existing record first.
  3. DigitalOcean. doctl apps spec get <app-id> > spec.yaml, append {domain: <host>, type: ALIAS}, then doctl apps update <app-id> --spec spec.yaml. App IDs: prod e4f6e538-4564-414d-8e24-3fc73d0fb6ac (lms-prod), beta 8fd569c5-f115-4ffe-ad10-fac66a3f78e1 (lms-beta-frontend).
  4. Poll. doctl apps get <app-id> until the domain status is ACTIVE. Timeout 10 minutes. On timeout, leave state as-is and fail loudly — do not roll back DNS.
  5. Prod only. PATCH the Cloudflare record to proxied:true. On beta, never.
  6. Firebase. Add <host> to authorized domains via the Identity Toolkit Admin API: GET/PATCH https://identitytoolkit.googleapis.com/admin/v2/projects/{project}/config, field authorizedDomains, read-modify-write. Projects: beta skill-pixel-beta, prod is the prod Firebase project.
  7. Optional last step. PATCH /api/v1/platform/tenants/{tenant_id}/domains/{domain_id} with {"status":"ACTIVE"} using a platform-admin token — or leave it to the operator in platform-admin so a human confirms.
Concurrency: a spec update triggers a redeploy of that app. The workflow must share the release workflow's concurrency group so it can never race a production release.

remove runs the same steps in reverse: set the domain DISABLED first, then Firebase, then DO, then Cloudflare.

Secrets needed

SecretStatusNotes
CLOUDFLARE_DNS_API_TOKENNEWAPI token scoped Zone → DNS → Edit on skillpixel.vn. The local wrangler OAuth login has no DNS scope, so it cannot be reused.
DIGITALOCEAN_ACCESS_TOKENEXISTSAlready used by the release workflows.
Firebase service account JSONEXISTSAlready in Actions / Infisical.

Two things we are not doing REJECTED

  • DO wildcard domain. wildcard: true needs DNS-01 TXT validation, and DO never surfaces the TXT flow for CNAME-managed (externally hosted DNS) domains. The only fixes are DO-managed DNS — against policy — or a DO support ticket.
  • Cloudflare Worker router. Rejected 2026-08-02: too complex, it conflicts with the same-origin /api rules, and Origin Rules host override is Enterprise-only.

Failure modes

FailureWhat happensWhat to do
DNS created, DO step failsCNAME points at the app but the app does not serve that host.Re-run the workflow. Every step is idempotent.
DO ACTIVE, Firebase failsSite loads, but login breaks on that host.Surface the error clearly in the run summary and re-run step 6.
Beta record set to proxied by mistakeTLS handshake fails at the Cloudflare edge — no cert for the nested wildcard.The workflow refuses to proxy on beta. Fix by hand if it ever happens.
Tenant still PROVISIONEDRouting returns 400 even though DNS and cert are fine.Check tenant status before provisioning; move the tenant to ACTIVE.
First use: historyclass.skillpixel.vn on prod, as part of SP-476 (Phase 2).

3Custom domains — recommended approach PROPOSAL · PHASE 4 · SP-477

Different problem: we do not own the DNS, so we cannot just add a record.

tenant adminenters tenantc.com / learn.tenantc.com
our APIcreate CF custom hostname, store PENDING, show TXT/CNAME instructions
customersets DNS
pollerCF status active → set verified_at
operatorset ACTIVE
platformFirebase add, canonical host flip, 301 from managed subdomain

Why Cloudflare for SaaS instead of building it

  • Verification is theirs. Cloudflare runs the TXT/HTTP ownership check and we read the result over the API.
  • Certificates are theirs. Cloudflare issues and renews the cert for the customer's hostname. No DO domain registration at all.
  • No backend change. The customer CNAMEs to a fallback origin (for example customers.skillpixel.vn) which points at lms-prod, so /api stays same-origin and Host resolution works as-is — SP-606/SP-607 already handle the hostname.
  • Cost. 100 custom hostnames included, then roughly $0.10 per hostname per month. Verify current pricing before committing.

State machine

StateMeansRoutes?Side effects on entry
PENDINGCF custom hostname created, waiting on customer DNS.NoShow TXT/CNAME instructions to the tenant admin.
VERIFIEDCF reports the hostname active. verified_at is set.NoSet verified_at. Nothing else.
ACTIVELive for real users.YesAdd to Firebase authorized domains; canonical host becomes the custom domain; the managed subdomain 301s to it.
DISABLEDTurned off.NoRemove from Firebase; canonical host falls back to the managed subdomain.
ACTIVE is refused when verified_at is null, or when the Firebase cap is hit — warn at 75, block at 90 active custom domains (spec §2.x). This closes the known debt item: "Custom domains cannot reach ACTIVE without verification" (debt.md).

Apex (root) domains FACT

Cloudflare for SaaS verification (TXT/HTTP) and certificate issuance work the same for an apex domain as for a subdomain. The only apex problem is DNS: a root domain cannot carry a CNAME under standard DNS, and our fallback origin is a hostname, not a fixed IP — DO App Platform has no static IP.

SituationWhat the customer doesResult
DNS supports ALIAS / ANAME / CNAME flattening at the apexPoint tenantc.com at the fallback origin the same way as a subdomain. Cloudflare, AWS Route 53, DNSimple, Namecheap, NS1, DNS Made Easy and Google Cloud DNS (ALIAS) all support this.Apex works directly.
DNS provider does not support itMove DNS to Cloudflare — free plan, CNAME flattening is built in, nameserver change only, the registrar stays where it is. This is the default onboarding instruction.Apex works directly.
Customer cannot change DNS providerwww.tenantc.com becomes the served primary host; the customer uses registrar/DNS "domain forwarding" for tenantc.comhttps://www.tenantc.com (GoDaddy, Namecheap and the Google Domains successors all offer it).Apex reaches the site via a redirect.

Product rule PROPOSAL — onboarding always registers both tenantc.com and www.tenantc.com for a custom-domain tenant. The customer picks which one is primary (is_primary already exists); the other one 301s to the primary. Verification is per hostname, so both must be verified before ACTIVE.

  • Rejected: Cloudflare "Apex Proxying" / BYOIP — Enterprise-only. REJECTED
  • Deferred (v2 option): our own redirect host on a fixed IP — for example a small DO droplet with a reserved IP — so customers on non-ALIAS DNS can use an A record for apex instead of registrar forwarding. DECISION NEEDED only for whether we build this v2 redirect host.

Backend / API changes needed

  • Create the CF custom hostname when a domain of type CUSTOM is created.
  • Status poller. Options: a cron fanned out per tenant, or one global job. Recommend one global job — this is platform-level work, not tenant-level.
  • Cap counter query plus the check inside update_domain.
  • Firebase add on ACTIVE, remove on DISABLED.
  • TenantUrlResolver needs no change — its canonical host rule already says custom domain first, then managed subdomain.

Decisions needed before Phase 4 starts

  1. Apex domainsDECIDED
    Apex domains are supported from v1 via ALIAS/CNAME-flattening, Cloudflare DNS migration, or www-primary + registrar forwarding (see Apex card). Open sub-question: whether to run our own fixed-IP redirect host (v2).
  2. Shared auth domainDECISION NEEDED
    The Firebase cap (block at 90) is the real long-term limit. Option 1: keep per-domain authorized domains — simple, capped. Option 2: a shared auth.skillpixel.vn authDomain with redirect-based sign-in — uncapped, more work, cross-site cookie implications. Decide before the first custom domain ships.
  3. Scope boundaryPROPOSED SCOPE CUT
    Email sender domain, OG/SEO tags and canonical URLs on custom domains are tenant settings, not routing. Keep them out of SP-477 scope.
  4. Who flips ACTIVEDECISION NEEDED
    Automatic on verification, or an operator click in platform-admin. Recommendation: operator click in v1.

4Side by side

DNS owner TLS issuer Verification DO registration? Firebase domain Automation home Cost Phase / ticket
Managed subdomain Us — Cloudflare zone skillpixel.vn DO (Let's Encrypt); Cloudflare Universal SSL once proxied on prod None — we own the zone Yes, ALIAS per host One per host GitHub Actions provision-tenant-domain.yml $0 Phase 2 — SP-476
Custom domain
tenantc.com / learn.tenantc.com
Customer — apex + subdomains (see §3) Cloudflare for SaaS Cloudflare TXT/HTTP check → verified_at No — fallback origin only One per host, capped (warn 75 / block 90) Backend API + global status poller 100 included, then ~$0.10/host/month Phase 4 — SP-477

5Rollout order

  1. Create the Cloudflare DNS-edit API token and add it to GitHub secrets as CLOUDFLARE_DNS_API_TOKEN.
  2. Build provision-tenant-domain.yml. Track it as an SP-476 sub-task.
  3. Dry-run on beta with a throwaway label, for example canary2.beta.skillpixel.vn, then run remove and confirm it cleans up.
  4. Provision historyclass.skillpixel.vn on prod, then activate the domain in platform-admin.
  5. After a few weeks stable: SP-477 design review using section 3, and settle the four decisions.
  6. Later: move provisioning behind the platform API — option B.