Skip to main content

CD model + custom-domain provisioning

reference · devops · updated 2026-09-01 · source

How tedos ships changes and how a client's custom domain goes live. Two ideas do the heavy lifting:

  1. Per-org config is RUNTIME, not a build — changing a client's branding/modules/features/domain is a Postgres write, never a git push or a Vercel deploy.
  2. One multi-tenant deployment serves every client domainapps/portal (the tenant app, ADR-017) is deployed once; custom domains are added to that one Vercel project via the Domains API and resolved by Host at request time.

Decided + built in effort #922 (Client Configuration Framework), sub #930; console UI + hardening in effort #1444 (the target app moved from the retired comprender to apps/portal with ADR-017). Related: comprender-mvp-e1.md (tenant topology) · plan-engine.md (the GitHub/Vercel sync that mirrors deploys into the graph).

1. Runtime config vs. code change (the split)

ChangeWhere it livesWhat it triggers
Branding tokens, enabled modules, feature flags, tier, locale, custom domainPostgres rows (org_config / feature_flag_override / control.project)Nothing — a DB write (custom domain also calls the Vercel Domains API; still no git push, no deploy). The client app reads it at request time.
App code, packages, schema, UIgit → developmainA Vercel deploy of the affected app only (turbo-ignore).
  • The operator console writes config through the operator-guarded API (PATCH /projects/:id, PATCH /console/projects/:id/features/:key, POST /console/projects/:id/domain[/verify]). These are pure DB writes + Vercel Domains API calls — nothing in the config write-path pushes git or triggers a deploy.
  • The multi-tenant client app reads GET /tenant/config server-side, resolving the tenant by the request Host header (no-store, DF5). So flipping a client's branding or modules is instant and build-free — no rebuild, no redeploy, no per-tenant artifact.

2. How a custom domain goes live (the console flow, effort #1444)

The backbone (sub #923/#924) stores project.domain (UNIQUE since #1446 — one tenant per domain, NULLs free) + domainVerificationToken + domainVerifiedAt. Sub #930 makes that domain actually route; the console screen Configurar → Dominios (/org/<slug>/configurar/dominios, sub #1445) drives the whole flow:

  1. Operator saves the org's domain on the Dominios screen → POST /console/projects/:id/domain (via the console BFF apps/console/src/app/api/console/projects/[id]/domain/route.ts) persists it unverified and adds it to the client app's Vercel project ("Add a Domain to a Project", Vercel Domains API). The response carries the DNS challenge (TXT/CNAME records), rendered with copy buttons.
  2. Client publishes the DNS records at their authoritative DNS provider.
  3. Operator hits VerificarPOST /console/projects/:id/domain/verify polls Vercel; once ownership + SSL are confirmed it stamps domainVerifiedAt and clears the one-time token. Vercel issues the TLS cert automatically.
  4. The domain now points at the one portal deployment; GET /tenant/config (apps/api/src/routes/config/config-routes.ts) resolves the tenant by Host against project.domain. No new build, no new app — just a new domain on the existing deployment.

The screen derives a shared DnsStatus per domain (pendingverifyingactive, error on a failed call — @tedos/shared dnsStatusSchema) from the real API responses.

Env (names only, never hardcoded; fails closed / 503 when unset — mirrors GET /console/infra; the console surfaces the 503 as "provisioning not configured"). Documented in apps/api/.env.example; minting the token is the operator's MANUAL step in the Vercel dashboard:

  • VERCEL_TOKEN — Vercel access token (domains write scope).
  • VERCEL_TEAM_ID — optional; scopes calls to the tuempresadigital team.
  • VERCEL_CLIENT_PROJECT_ID — the Vercel project id/name of the multi-tenant client app (apps/portal) that every tenant domain is added to.

3. Selective builds — turbo-ignore

Every app's vercel.json carries:

"ignoreCommand": "npx turbo-ignore @tedos/<app>",
"git": { "deploymentEnabled": { "main": true, "develop": false } }
  • turbo-ignore asks Turborepo whether the app (or anything in its dependency graph) changed since the last deploy; if not, Vercel skips the build. A code change rebuilds only the affected app, not the whole monorepo.
  • deploymentEnabled ships from main only; develop is integration (no auto-deploy).
  • The apps are consistent on both settings — verified in #930.

4. One deployment, many domains

@tedos/portal is the multi-tenant tenant app (ADR-017: operators AND students, role-split on ONE host per tenant): deployed once, it serves every tenant host — <tenant>.tuempresa.digital and any verified client-owned domain (e.g. tienda.comprender.mx). New tenants don't get a new deployment — their domain is added to the portal's Vercel project (step 2 above) and resolved by Host. This is what keeps onboarding a client a config operation, not an engineering/deploy operation. (The comprender app this doc originally targeted is retired — ADR-017.)