← Back to Skills

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.md
Download .md

Coming soon via CLI:

tawa chaac install tawa-custom-domains

Details

Format
Rule
Category
configure
Version
1.0.4408
Tokens
~3,233
Updated
2026-10-13
platformdomainsdnstlscloudflarecert-manageringress