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…
Ruleconfigure
About
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.
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# 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 record | CF auto-creates CNAME | **One CNAME → `customers.tawa.pro`**, client keeps their DNS | **A record → ingress LB IP** |
| TLS terminated at | Cloudflare edge | Cloudflare edge (custom hostname) | **In-cluster (cert-manager / Let's Encrypt)** |
| Apex/root domains | CF flattening | **not supported** (use `--direct` or forward to www) | **native (A record is legal at apex)** |
| CDN / WAF / DDoS | yes | yes | no (origin hit directly) |
| `verify` checks for | n/a (auto) | custom hostname + cert status, and `CNAME → customers.tawa.pro` | `A → ingress LB IP` |
| Needs CF zone / NS on CF | yes | **no** | **no** |
**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
```bash
# 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 name | Result |
|---|---|
| none, or only TXT / MX / CAA / NS | created; the other records are left alone |
| CNAME already pointing at this service's target | proceeds, idempotent |
| CNAME to a **replaceable target** (below) | replaced; response carries `replacedRecord: { type, content }` |
| any other CNAME / A / AAAA | **409 `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:
```jsonc
{ "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.
```bash
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"
```
| State | Meaning | Outstanding |
|---|---|---|
| `pending-cname` | The CNAME does not resolve to `customers.tawa.pro` yet | the CNAME (and optional TXT) |
| `pending-validation` | CNAME is in place; the custom hostname or certificate is not active yet | validation records, if any |
| `active` | CNAME resolves and the certificate is issued; `dnsVerified` is set | none |
- `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:
```bash
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.
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-custom-domains.mdComing soon via CLI:
tawa chaac install tawa-custom-domainsDetails
- Format
- Rule
- Category
- configure
- Version
- 1.0.4408
- Tokens
- ~3,233
- Updated
- 2026-10-13
platformdomainsdnstlscloudflarecert-manageringress