# 21-Apr-2026 — attach custom domains to existing environments [#242](https://github.com/nbarrett/ngx-ramblers/issues/242)

## [build 618](https://github.com/nbarrett/ngx-ramblers/actions/runs/24696664426) — [commit c582a9b](https://github.com/nbarrett/ngx-ramblers/commit/c582a9b793be579eeadc55ae7897c8b200d35842)

_____

- Add first-class Cloudflare + Fly certificate plumbing for attaching an
arbitrary custom domain to an existing environment, exposed as an **Add / Remove Custom Domain** action on the Environment Management Modify
tab. Cuts out the manual `flyctl certs add` + hand-edited DNS dance
previously required per domain cutover.
- See screenshot below for how custom domain configuration can now be added to the modify stage of environment set up:

![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/98732741-a8ae-4ec3-b112-7f819d45b267.jpeg)



## Config model

- New `CustomDomainStatus` enum (`pending` / `attached` / `failed`)
and `CustomDomainEntry` interface (`hostname`, `addedAt`, `status`,
optional `zoneId` and `message`).
- `EnvironmentConfig` gains `customDomains?: CustomDomainEntry[]`,
persisted on the environments config doc under each environment.
- `ExistingEnvironment` carries the list out to the admin UI.
- New `CustomDomainResponse` for add/remove API calls.

## Admin UI (Modify tab)

- New "Custom Domains" panel on `environment-management.ts` with:
- Hostname input + Add Custom Domain button (disabled while any
operation is busy).
- Per-domain table listing hostname, status badge
(`attached`/`pending`/`failed`), added-at, and a Remove action.
- Inline error + scrollable session-log message stream wired through
the existing `SessionLogsComponent`.
- `addCustomDomain` / `removeCustomDomain` client methods on
`EnvironmentSetupService` hit the new routes.
- Resetting the Modify tab now also clears the custom-domain scratch
state.
- `ManageAction` enum replaces stringly-typed `'modify'` / `'destroy'`
values across the component.
- Environment empty-state now renders the "No environments configured
yet" alert unconditionally when the list is empty, instead of showing
a spinner-in-dropdown during the initial fetch.

## Server plumbing

- `server/lib/cli/commands/subdomain.ts` grows two exported functions:
- `addCustomDomainForEnvironment(envName, hostname)`
- validates the
hostname (FQDN, no scheme, no path), loads env config, verifies the
Cloudflare token, resolves the zone via the new `zoneForHostname`,
ensures the Fly app has an IPv4 (allocates a shared one if missing),
upserts A / AAAA records for the hostname, requests a Fly
certificate, reads back the issuance status, and persists a
`CustomDomainEntry` on the environments config doc regardless of
outcome so the UI shows `pending` / `attached` / `failed`.
Idempotent: existing DNS records and certificates are detected and
skipped.
- `removeCustomDomainForEnvironment(envName, hostname)`
- deletes
matching DNS records, removes the Fly certificate, and clears the
entry from the config doc. Tolerant of missing zones and missing Fly
tokens.
- Duplicate-hostname guard refuses to attach a hostname already claimed
by another environment.
- Admin attach flow auto-updates `group.href` in system config when an
apex custom domain is attached, removing a previously-undiscoverable
manual step.

## Cloudflare helpers

- `CloudflareZone` model type (id / name / status).
- `listZones(apiToken, name?)` and `zoneForHostname(apiToken, hostname)`
in `cloudflare-dns.ts`: walks the label chain
(`sub.example.co.uk`, `example.co.uk`, `co.uk`) and resolves the
first zone Cloudflare reports back under that exact name.
- `CLOUDFLARE_API_BASE` + `cloudflareApi.*` helper centralises all
`https://api.cloudflare.com/client/v4/...` URL construction; every
call site updated.

## MX record handler zone-awareness

- `/cloudflare/email-routing/mx-records` GET and POST handlers now
resolve the zone for the current `baseDomain` via `zoneForHostname`
and operate on that zone instead of the default NGX zone. Previously,
custom-domain MX state was read from (and written to) the wrong zone,
producing false "Missing MX records" status and Cloudflare
"An identical record already exists" rejections.

## Routes

- `POST /environment-setup/add-custom-domain/:environmentName` and
`POST /environment-setup/remove-custom-domain/:environmentName` behind
the existing `validateSetupAccess` guard. Errors are surfaced verbatim
to the client (including `EnvironmentNotFoundError` -> 404) so the
session-log panel shows the Cloudflare / Fly reason rather than a
generic 500.
- `GET /environment-setup/existing-environments` now includes
`customDomains` on each environment so the UI can render the list.

## Brevo domain authentication automation

- `authenticateSendingDomain` now accepts optional `cloudflareDnsConfig`
and `baseDomainOverride`, so it can authenticate any domain in any
zone - not only the NGX default. When no override is passed it
resolves the correct zone via `zoneForHostname(apiToken, domainName)`
instead of defaulting to the configured NGX zone; the previous
default caused DKIM/brevoCode TXT writes to collide in the wrong
zone.
- New `ensureDkimAvailable` helper: when Brevo's
`GET /senders/domains/configuration` returns no DKIM for a registered
domain, delete and re-register, then use the `POST /senders/domains`
response body's `dnsRecords` directly (Brevo's own UI's "Authenticate
the domain yourself" shortcut). Eliminates the manual detour into
`app.brevo.com` previously required for fresh custom domains.
- Refactored the inline try/catch `let` ladders in
`authenticateSendingDomain` into `configureDnsRecords`,
`requestAuthentication`, and `resolveCfAndBaseDomain` helpers that
return typed results.
- `brevoDomainsUrl` is now included on every non-authenticated outcome
(including DNS-failure early returns), not just the "missing DKIM"
case, so admins always have an escape hatch to Brevo's UI.
- `brevo/domains/config` route returns `response: null` with HTTP 200
for Brevo 404s instead of bubbling an error, so the frontend console
and `MailService` logger stop spamming on the expected "not
registered yet" state.

## Brevo sending-domain switch

- New `POST /brevo/domains/switch` route + `switchBrevoSendingDomain`
orchestrator re-authenticate a target domain in Brevo, create the
DKIM/SPF records in the correct Cloudflare zone, and mass-rewrite
every sender from `@oldDomain` to `@newDomain`. Reuses the existing
senders helpers (`listBrevoSenders`, `registerBrevoSender`,
`deleteBrevoSenderById`) - no duplication of Brevo API code.
- `listBrevoSenders` extracted to a shared helper; `deleteBrevoSenderById`
exposed for reuse from the switch flow.
- Mail Senders tab surfaces a warning banner + "Switch sending domain
to <canonical>" button when the Brevo-authenticated domain differs
from `group.href`. Returned `logs[]` stream through
`SessionLogsComponent`; on success, `baseDomain`, domain status, MX
records and senders all reload.

## Button-wrapper disabled-state fix

- `<app-button-wrapper>` no longer uses Bootstrap's `.disabled` class
(which applies `pointer-events: none`) to render the disabled state;
replaced with a local `is-disabled` opacity-only class. Previously,
clicks on visually-disabled buttons fell through the wrapper's
`blockClick` handler and fired the caller's `(click)` binding anyway.
Affects all 23 `<app-brevo-button>` callers across the app with
`[disabled]` bindings.

## Subdomain refactors

- `server/lib/cli/commands/subdomain.ts`
- pulled the two remaining
`let`-driven if/else ladders in the check flow into
`reconcileCustomDomainDns` (does DNS reconciliation, returns a
`dnsProblem | undefined`) and `classifyCertStatus` (pure function
mapping `cert` + `dnsProblem` to `{ status, message }`).