Custom Domains

Every service gets a platform hostname automatically on deploy ({service}.tawa.pro, {service}.sandbox.tawa.pro, {service}.uat.tawa.pro). To put your own domain in front of a service, use tawa domain. There are three DNS modes, picked by how the domain's DNS is managed and whether Cloudflare can be used.

Mode comparison

--cloudflare--external (Cloudflare for SaaS)--direct (BYO-DNS)
Customer DNS recordCF auto-creates CNAMEOne CNAME → customers.tawa.pro, client keeps their DNSA record → ingress LB IP
TLS terminated atCloudflare edgeCloudflare edge (custom hostname)In-cluster (cert-manager / Let's Encrypt)
Apex/root domainsCF flatteningnot supported (use --direct or forward to www)native (A record is legal at apex)
CDN / WAF / DDoSyesyesno (origin hit directly)
verify checks forn/a (auto)custom hostname + cert status, and CNAME → customers.tawa.proA → ingress LB IP
Needs CF zone / NS on CFyesnono

Default to --cloudflare for domains we manage; use --external when a client owns the domain and keeps their DNS. Use --direct only when the customer cannot move their nameservers to Cloudflare (e.g. DNS stuck at HostGator/cPanel) but can still set an A record.

Commands

# Cloudflare-managed (auto DNS + TLS) — preferred
tawa domain add portal.example.com --cloudflare

# Client-owned domain (client adds one CNAME to customers.tawa.pro; TLS at our edge)
tawa domain add portal.example.com --external

# Direct A record (BYO-DNS, TLS issued in-cluster) — for when Cloudflare is not an option
tawa domain add example.com --direct
tawa domain verify example.com
tawa deploy --prod

tawa domain list
tawa domain status example.com
tawa domain remove example.com

Cloudflare mode never takes over an existing record

--cloudflare writes the CNAME for you, which makes it easy to aim at a name that already exists. Since iec-builder#163, a first-time add refuses when Cloudflare already has a record for that name pointing somewhere other than this service:

409 DNS_RECORD_EXISTS
A CNAME record for 'bio.tawa.pro' already exists and points at '…', not this service.
Refusing to replace it.

Before that fix the builder updated the record in place (or deleted and recreated it on a type change), so tawa domain add bio.tawa.pro --cloudflare, run from any service, would have repointed Bio-ID. Platform hostnames and hand-made records (builder.tawa.pro, git.tawa.pro) are not custom domains, so the older DOMAIN_TAKEN check never covered them.

Only address records — CNAME, A and AAAA — count. A TXT, MX, CAA or NS record at the same name is not something a custom domain replaces, so it neither blocks the add nor gets touched by it (iec-builder#236).

Existing records at the nameResult
none, or only TXT / MX / CAA / NScreated; the other records are left alone
CNAME already pointing at this service's targetproceeds, idempotent
CNAME to a replaceable target (below)replaced; response carries replacedRecord: { type, content }
any other CNAME / A / AAAA409 DNS_RECORD_EXISTS, record untouched
(re-adding a domain this service already owns)resync, unchanged behaviour

Replaceable targets (iec-builder#270). Some records are placeholders that exist precisely so a real service can take the name over: a parked www CNAMEd at the old marketing host, or at the apex it sits under. An address record is replaceable, not foreign, when its target is

  • in DNS_REPLACEABLE_TARGETS (builder env, comma-separated; default sites.insureco.io,hypesites.tawa.pro), or
  • the zone apex itself (www.clubmvp.co CNAME clubmvp.co) — except inside the platform domain.

Any foreign record at the name still wins: a name with one replaceable and one foreign record is refused. A record pointing at another tawa.pro service hostname is never replaceable unless that hostname is explicitly listed.

If replacing the record really is the point, remove it first, or call POST /domains/add with "overwriteExistingRecord": true. Overwriting replaces only the conflicting address records (a CNAME replaces A/AAAA; an A or AAAA replaces a CNAME). TXT, MX, CAA and NS records are never deleted — if Cloudflare refuses the new record because of one, the add fails with Cloudflare's error and nothing is removed.

Correction (2026-10-03): before iec-builder#236 the builder looked up "the" record at a name without regard to its type and used whichever Cloudflare listed first. An apex carrying a google-site-verification TXT was therefore read as a foreign record, and tawa domain add <apex> --cloudflare always failed with 409 DNS_RECORD_EXISTS. With overwriteExistingRecord: true the same bug would have deleted the TXT to make room. policyblockchain.com was set up on 2026-10-03 around it, with tawa domain add --external, CNAMEs created by hand in Cloudflare, and dnsVerified set directly in the builder DB. Do not repeat that workaround; --cloudflare now works on such an apex.

Verifying a proxied or flattened CNAME

tawa domain verify for --external (and --cloudflare) looks for a public CNAME to the expected target. A proxied CNAME, and every CNAME at an apex (Cloudflare flattens it), never answers a CNAME query — the name resolves to Cloudflare edge addresses instead. So when the domain resolves but shows no CNAME, verify asks Cloudflare directly and passes if:

  1. this account holds the domain's zone and the zone is active (its nameservers point at Cloudflare), and
  2. that zone has a CNAME at the name pointing at the expected target.

resolvedTarget then reads CNAME: <target> (proxied/flattened by Cloudflare). A zone outside this Cloudflare account still needs a public CNAME.

A Cloudflare-mode add also patches the service's live ingress and syncs its OAuth redirect URIs, so the host serves traffic without a redeploy. The domain is stored on the service, and later deploys keep it.

Domains not yet in the platform Cloudflare account

--cloudflare used to fail with No Cloudflare zone found… for any domain whose zone was not already in the platform's Cloudflare account. Since iec-builder#270 the builder creates the zone (type full) for the registrable apex and writes the CNAME into it: www.example.com creates example.com; known two-part suffixes are respected (shop.example.co.uk creates example.co.uk). The account is CLOUDFLARE_ACCOUNT_ID, else the token's first account.

The new zone is pending until the registrar delegates the domain to Cloudflare. The add response says so, and carries what to do:

{ "dnsVerified": true,
  "zone": { "id": "…", "name": "clubmvp.co", "status": "pending", "created": true,
            "nameServers": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"],
            "originalNameServers": ["ns1.hostgator.com", "ns2.hostgator.com"] },
  "message": "DNS is configured, but clubmvp.co is not live yet: set its nameservers at the registrar to ada.ns.cloudflare.com, bob.ns.cloudflare.com (currently ns1.hostgator.com, ns2.hostgator.com)." }

dnsVerified is true because the record is in place; the site is not reachable until the nameservers change. Set them at the registrar, then re-run the add (or check GET /domains/:domain/status, which reads the zone live and returns zoneStatus and nameServers). zone is present on every Cloudflare-mode add, with created: false for a zone the account already held. The stored domain record keeps zoneStatus and nameServers.

Client-owned domains (--external, Cloudflare for SaaS)

When a client owns the domain and keeps their DNS at their own registrar, --external registers a Cloudflare custom hostname (Cloudflare for SaaS, DV certificate by HTTP validation). The client changes nothing but one record. TLS terminates at our Cloudflare edge; no nameserver change, no Cloudflare account for the client.

tawa domain add portal.client.com --external
# → Ask the domain owner to add CNAME portal.client.com → customers.tawa.pro.
#   Optional, to issue TLS before switching: TXT _cf-custom-hostname.portal.client.com = <value>.
tawa domain verify portal.client.com        # re-run until state is "active"
StateMeaningOutstanding
pending-cnameThe CNAME does not resolve to customers.tawa.pro yetthe CNAME (and optional TXT)
pending-validationCNAME is in place; the custom hostname or certificate is not active yetvalidation records, if any
activeCNAME resolves and the certificate is issued; dnsVerified is setnone
  • verify and status read the live custom hostname and certificate status from Cloudflare and the CNAME from public DNS. dnsVerified becomes true only at active.
  • The ingress host is patched on add; traffic only arrives once the client's CNAME exists.
  • Apex is not supported (a CNAME cannot sit at an apex): add returns 400 EXTERNAL_APEX_UNSUPPORTED. Use --direct (an A record), or forward the apex to www at the registrar and add the www hostname.
  • If Cloudflare refuses the custom hostname (for example Cloudflare for SaaS is not enabled on the zone), add and verify return 502 SAAS_UNAVAILABLE with the Cloudflare error. There is no silent fallback.
  • Records added before this change (no custom hostname) are migrated by running tawa domain verify <domain>, which creates the custom hostname.
  • tawa domain remove deletes the custom hostname.

Builder settings: SAAS_ZONE_ID (default CLOUDFLARE_ZONE_ID), SAAS_CNAME_TARGET (default customers.tawa.pro, kept as a proxied record at the ingress and set as the zone's fallback origin automatically), SAAS_ENABLED (false restores the record-only behaviour).

Direct mode (BYO-DNS) — how it works

  1. tawa domain add <domain> --direct registers the domain on the service with dnsProvider: 'direct'. The expectedTarget is the cluster ingress LoadBalancer IP (INGRESS_TARGET on the builder). The CLI prints the A record to add.
  2. The customer adds an A record for the domain (and www, registered separately) pointing at that IP. Apex domains work natively — no CNAME flattening needed.
  3. tawa domain verify <domain> checks that the A record resolves to the ingress LB IP (not a CNAME). On success the record is marked dnsVerified.
  4. tawa deploy injects the verified direct-mode hosts into the service's builder-managed ingress, adds a tls block (secretName: {service}-custom-tls) and the annotation cert-manager.io/cluster-issuer: {CERT_MANAGER_CLUSTER_ISSUER} (default letsencrypt-prod).
  5. cert-manager solves a Let's Encrypt HTTP-01 challenge over port 80 (which works precisely because the A record already lands on the ingress), issues the cert into the TLS secret, and auto-renews it (~30 days before expiry). No Cloudflare, no manual cert work.

Cloudflare/external domains are not given an in-cluster TLS block — Cloudflare terminates their TLS. Only direct domains get cert-manager TLS.

Platform requirements for direct mode

  • cert-manager must be installed in the cluster with a ClusterIssuer named to match CERT_MANAGER_CLUSTER_ISSUER (default letsencrypt-prod, HTTP-01 solver on ingressClassName: nginx).
  • INGRESS_TARGET on the builder must be set to the ingress LB IP. tawa domain add --direct fails with DIRECT_MODE_UNAVAILABLE if it is unset or not an IP.

Trade-offs / operational notes

  • LB IP coupling. Direct-mode customers hardcode the ingress LB IP in their DNS. If that IP ever changes (cluster migration, LB recreation) every direct-mode domain breaks. Pin a reserved/floating IP for the ingress LoadBalancer before relying on this at scale.
  • No edge protection. Direct-mode traffic hits the origin raw — no Cloudflare CDN, WAF, or DDoS shielding, and the origin IP is public. Fine for static marketing sites; weigh it for anything sensitive.
  • Renewal dependency. HTTP-01 re-validates every ~60 days, so the A record must stay pointed at the platform and port 80 must remain reachable.
  • TLS for non-direct hosts on the same ingress is unaffected — nginx only force-redirects / serves certs for hosts present in a tls block.

Apex + www

Direct mode registers one record per hostname. For apex + www, add both:

tawa domain add example.com --direct
tawa domain add www.example.com --direct
tawa domain verify example.com && tawa domain verify www.example.com
tawa deploy --prod

Both hosts are added to the same {service}-custom-tls secret, so a single Let's Encrypt cert covers them.

Last updated: October 6, 2026