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.
0TL;DR
<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_dispatchworkflow. PROPOSAL - Move it behind the platform API later, not now.
- Do not pursue the DO wildcard. REJECTED
tenantc.com / learn.tenantc.com
- Use Cloudflare for SaaS custom hostnames with
lms-prodas 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
Backend routing DONE · BETA-VERIFIED 2026-08-20
Hostresolves to a tenant only when it matches an ACTIVEtenant_domainof 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 apexskillpixel.vnandlocalhost. Customer apex domains such astenantc.comare accepted (verified 2026-08-21 againstnormalize_host_header/is_reserved_hostname). It also refusesPLATFORMtype (SP-606). - Activate/deactivate invalidates routing caches across all instances within about 1 second, via a Redis generation key (SP-607).
POST /api/v1/platform/tenants start as PROVISIONED. Domains only route once the tenant is ACTIVE.Domain rows and DO app domains right now
| Where | Hostnames |
|---|---|
Beta tenant_domains | beta.skillpixel.vn (PLATFORM); historyclass.beta.skillpixel.vn, tenanta.beta.skillpixel.vn (MANAGED_SUBDOMAIN) |
| Prod DO app | skillpixel.vn (PRIMARY), canary.skillpixel.vn (ALIAS) |
| Beta DO app | tenanta, 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.
- DigitalOcean: add
<host>as an ALIAS domain on the app viadoctl apps update. Exact domains auto-validate over CNAME; the Let's Encrypt cert lands in about 2 minutes. - Cloudflare: CNAME
<host>→ the app's default ingress hostname, DNS only (grey cloud). - 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. - Firebase: add
<host>to authorized domains. No wildcard support. - 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.
provision-tenant-domainThree 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_atwhen 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
- 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. - Cloudflare.
POST /client/v4/zones/{zone_id}/dns_recordswith type CNAME, name<host>, content = the app default ingress (lms-prod-xxxx.ondigitalocean.appor the beta equivalent — read it withdoctl apps get --format DefaultIngress),proxied:false. Idempotent: look up an existing record first. - DigitalOcean.
doctl apps spec get <app-id> > spec.yaml, append{domain: <host>, type: ALIAS}, thendoctl apps update <app-id> --spec spec.yaml. App IDs: prode4f6e538-4564-414d-8e24-3fc73d0fb6ac(lms-prod), beta8fd569c5-f115-4ffe-ad10-fac66a3f78e1(lms-beta-frontend). - 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. - Prod only.
PATCHthe Cloudflare record toproxied:true. On beta, never. - Firebase. Add
<host>to authorized domains via the Identity Toolkit Admin API:GET/PATCH https://identitytoolkit.googleapis.com/admin/v2/projects/{project}/config, fieldauthorizedDomains, read-modify-write. Projects: betaskill-pixel-beta, prod is the prod Firebase project. - 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 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
| Secret | Status | Notes |
|---|---|---|
CLOUDFLARE_DNS_API_TOKEN | NEW | API token scoped Zone → DNS → Edit on skillpixel.vn. The local wrangler OAuth login has no DNS scope, so it cannot be reused. |
DIGITALOCEAN_ACCESS_TOKEN | EXISTS | Already used by the release workflows. |
| Firebase service account JSON | EXISTS | Already in Actions / Infisical. |
Two things we are not doing REJECTED
- DO wildcard domain.
wildcard: trueneeds 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
/apirules, and Origin Rules host override is Enterprise-only.
Failure modes
| Failure | What happens | What to do |
|---|---|---|
| DNS created, DO step fails | CNAME points at the app but the app does not serve that host. | Re-run the workflow. Every step is idempotent. |
| DO ACTIVE, Firebase fails | Site 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 mistake | TLS 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 PROVISIONED | Routing returns 400 even though DNS and cert are fine. | Check tenant status before provisioning; move the tenant to ACTIVE. |
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.
tenantc.com / learn.tenantc.comverified_atWhy 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 atlms-prod, so/apistays same-origin andHostresolution 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
| State | Means | Routes? | Side effects on entry |
|---|---|---|---|
| PENDING | CF custom hostname created, waiting on customer DNS. | No | Show TXT/CNAME instructions to the tenant admin. |
| VERIFIED | CF reports the hostname active. verified_at is set. | No | Set verified_at. Nothing else. |
| ACTIVE | Live for real users. | Yes | Add to Firebase authorized domains; canonical host becomes the custom domain; the managed subdomain 301s to it. |
| DISABLED | Turned off. | No | Remove from Firebase; canonical host falls back to the managed subdomain. |
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.
| Situation | What the customer does | Result |
|---|---|---|
| DNS supports ALIAS / ANAME / CNAME flattening at the apex | Point 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 it | Move 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 provider | www.tenantc.com becomes the served primary host; the customer uses registrar/DNS "domain forwarding" for tenantc.com → https://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.
TenantUrlResolverneeds no change — its canonical host rule already says custom domain first, then managed subdomain.
Decisions needed before Phase 4 starts
-
Apex domainsDECIDEDApex 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).
-
Shared auth domainDECISION NEEDEDThe 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.vnauthDomain with redirect-based sign-in — uncapped, more work, cross-site cookie implications. Decide before the first custom domain ships. -
Scope boundaryPROPOSED SCOPE CUTEmail sender domain, OG/SEO tags and canonical URLs on custom domains are tenant settings, not routing. Keep them out of SP-477 scope.
-
Who flips ACTIVEDECISION NEEDEDAutomatic 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 domaintenantc.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
- Create the Cloudflare DNS-edit API token and add it to GitHub secrets as
CLOUDFLARE_DNS_API_TOKEN. - Build
provision-tenant-domain.yml. Track it as an SP-476 sub-task. - Dry-run on beta with a throwaway label, for example
canary2.beta.skillpixel.vn, then runremoveand confirm it cleans up. - Provision
historyclass.skillpixel.vnon prod, then activate the domain in platform-admin. - After a few weeks stable: SP-477 design review using section 3, and settle the four decisions.
- Later: move provisioning behind the platform API — option B.