{"id":"69e6c22f2f5a6f491bc7894f","title":"2026 04 21","path":"how-to/committee/release-notes/2026-04-21","contentMarkdown":"# 21-Apr-2026 — attach custom domains to existing environments [#242](https://github.com/nbarrett/ngx-ramblers/issues/242)\n\n## [build 618](https://github.com/nbarrett/ngx-ramblers/actions/runs/24696664426) — [commit c582a9b](https://github.com/nbarrett/ngx-ramblers/commit/c582a9b793be579eeadc55ae7897c8b200d35842)\n\n_____\n\n- Add first-class Cloudflare + Fly certificate plumbing for attaching an\narbitrary custom domain to an existing environment, exposed as an **Add / Remove Custom Domain** action on the Environment Management Modify\ntab. Cuts out the manual `flyctl certs add` + hand-edited DNS dance\npreviously required per domain cutover.\n- See screenshot below for how custom domain configuration can now be added to the modify stage of environment set up:\n\n![](https://ngx-ramblers.org.uk/api/aws/s3/site-content/98732741-a8ae-4ec3-b112-7f819d45b267.jpeg)\n\n\n\n## Config model\n\n- New `CustomDomainStatus` enum (`pending` / `attached` / `failed`)\nand `CustomDomainEntry` interface (`hostname`, `addedAt`, `status`,\noptional `zoneId` and `message`).\n- `EnvironmentConfig` gains `customDomains?: CustomDomainEntry[]`,\npersisted on the environments config doc under each environment.\n- `ExistingEnvironment` carries the list out to the admin UI.\n- New `CustomDomainResponse` for add/remove API calls.\n\n## Admin UI (Modify tab)\n\n- New \"Custom Domains\" panel on `environment-management.ts` with:\n- Hostname input + Add Custom Domain button (disabled while any\noperation is busy).\n- Per-domain table listing hostname, status badge\n(`attached`/`pending`/`failed`), added-at, and a Remove action.\n- Inline error + scrollable session-log message stream wired through\nthe existing `SessionLogsComponent`.\n- `addCustomDomain` / `removeCustomDomain` client methods on\n`EnvironmentSetupService` hit the new routes.\n- Resetting the Modify tab now also clears the custom-domain scratch\nstate.\n- `ManageAction` enum replaces stringly-typed `'modify'` / `'destroy'`\nvalues across the component.\n- Environment empty-state now renders the \"No environments configured\nyet\" alert unconditionally when the list is empty, instead of showing\na spinner-in-dropdown during the initial fetch.\n\n## Server plumbing\n\n- `server/lib/cli/commands/subdomain.ts` grows two exported functions:\n- `addCustomDomainForEnvironment(envName, hostname)`\n- validates the\nhostname (FQDN, no scheme, no path), loads env config, verifies the\nCloudflare token, resolves the zone via the new `zoneForHostname`,\nensures the Fly app has an IPv4 (allocates a shared one if missing),\nupserts A / AAAA records for the hostname, requests a Fly\ncertificate, reads back the issuance status, and persists a\n`CustomDomainEntry` on the environments config doc regardless of\noutcome so the UI shows `pending` / `attached` / `failed`.\nIdempotent: existing DNS records and certificates are detected and\nskipped.\n- `removeCustomDomainForEnvironment(envName, hostname)`\n- deletes\nmatching DNS records, removes the Fly certificate, and clears the\nentry from the config doc. Tolerant of missing zones and missing Fly\ntokens.\n- Duplicate-hostname guard refuses to attach a hostname already claimed\nby another environment.\n- Admin attach flow auto-updates `group.href` in system config when an\napex custom domain is attached, removing a previously-undiscoverable\nmanual step.\n\n## Cloudflare helpers\n\n- `CloudflareZone` model type (id / name / status).\n- `listZones(apiToken, name?)` and `zoneForHostname(apiToken, hostname)`\nin `cloudflare-dns.ts`: walks the label chain\n(`sub.example.co.uk`, `example.co.uk`, `co.uk`) and resolves the\nfirst zone Cloudflare reports back under that exact name.\n- `CLOUDFLARE_API_BASE` + `cloudflareApi.*` helper centralises all\n`https://api.cloudflare.com/client/v4/...` URL construction; every\ncall site updated.\n\n## MX record handler zone-awareness\n\n- `/cloudflare/email-routing/mx-records` GET and POST handlers now\nresolve the zone for the current `baseDomain` via `zoneForHostname`\nand operate on that zone instead of the default NGX zone. Previously,\ncustom-domain MX state was read from (and written to) the wrong zone,\nproducing false \"Missing MX records\" status and Cloudflare\n\"An identical record already exists\" rejections.\n\n## Routes\n\n- `POST /environment-setup/add-custom-domain/:environmentName` and\n`POST /environment-setup/remove-custom-domain/:environmentName` behind\nthe existing `validateSetupAccess` guard. Errors are surfaced verbatim\nto the client (including `EnvironmentNotFoundError` -> 404) so the\nsession-log panel shows the Cloudflare / Fly reason rather than a\ngeneric 500.\n- `GET /environment-setup/existing-environments` now includes\n`customDomains` on each environment so the UI can render the list.\n\n## Brevo domain authentication automation\n\n- `authenticateSendingDomain` now accepts optional `cloudflareDnsConfig`\nand `baseDomainOverride`, so it can authenticate any domain in any\nzone - not only the NGX default. When no override is passed it\nresolves the correct zone via `zoneForHostname(apiToken, domainName)`\ninstead of defaulting to the configured NGX zone; the previous\ndefault caused DKIM/brevoCode TXT writes to collide in the wrong\nzone.\n- New `ensureDkimAvailable` helper: when Brevo's\n`GET /senders/domains/configuration` returns no DKIM for a registered\ndomain, delete and re-register, then use the `POST /senders/domains`\nresponse body's `dnsRecords` directly (Brevo's own UI's \"Authenticate\nthe domain yourself\" shortcut). Eliminates the manual detour into\n`app.brevo.com` previously required for fresh custom domains.\n- Refactored the inline try/catch `let` ladders in\n`authenticateSendingDomain` into `configureDnsRecords`,\n`requestAuthentication`, and `resolveCfAndBaseDomain` helpers that\nreturn typed results.\n- `brevoDomainsUrl` is now included on every non-authenticated outcome\n(including DNS-failure early returns), not just the \"missing DKIM\"\ncase, so admins always have an escape hatch to Brevo's UI.\n- `brevo/domains/config` route returns `response: null` with HTTP 200\nfor Brevo 404s instead of bubbling an error, so the frontend console\nand `MailService` logger stop spamming on the expected \"not\nregistered yet\" state.\n\n## Brevo sending-domain switch\n\n- New `POST /brevo/domains/switch` route + `switchBrevoSendingDomain`\norchestrator re-authenticate a target domain in Brevo, create the\nDKIM/SPF records in the correct Cloudflare zone, and mass-rewrite\nevery sender from `@oldDomain` to `@newDomain`. Reuses the existing\nsenders helpers (`listBrevoSenders`, `registerBrevoSender`,\n`deleteBrevoSenderById`) - no duplication of Brevo API code.\n- `listBrevoSenders` extracted to a shared helper; `deleteBrevoSenderById`\nexposed for reuse from the switch flow.\n- Mail Senders tab surfaces a warning banner + \"Switch sending domain\nto <canonical>\" button when the Brevo-authenticated domain differs\nfrom `group.href`. Returned `logs[]` stream through\n`SessionLogsComponent`; on success, `baseDomain`, domain status, MX\nrecords and senders all reload.\n\n## Button-wrapper disabled-state fix\n\n- `<app-button-wrapper>` no longer uses Bootstrap's `.disabled` class\n(which applies `pointer-events: none`) to render the disabled state;\nreplaced with a local `is-disabled` opacity-only class. Previously,\nclicks on visually-disabled buttons fell through the wrapper's\n`blockClick` handler and fired the caller's `(click)` binding anyway.\nAffects all 23 `<app-brevo-button>` callers across the app with\n`[disabled]` bindings.\n\n## Subdomain refactors\n\n- `server/lib/cli/commands/subdomain.ts`\n- pulled the two remaining\n`let`-driven if/else ladders in the check flow into\n`reconcileCustomDomainDns` (does DNS reconciliation, returns a\n`dnsProblem | undefined`) and `classifyCertStatus` (pure function\nmapping `cert` + `dnsProblem` to `{ status, message }`).","contentHtml":"<h1>21-Apr-2026 — attach custom domains to existing environments <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/242\">#242</a></h1>\n<h2><a href=\"https://github.com/nbarrett/ngx-ramblers/actions/runs/24696664426\">build 618</a> — <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/c582a9b793be579eeadc55ae7897c8b200d35842\">commit c582a9b</a></h2>\n<hr>\n<ul>\n<li>Add first-class Cloudflare + Fly certificate plumbing for attaching an\narbitrary custom domain to an existing environment, exposed as an <strong>Add / Remove Custom Domain</strong> action on the Environment Management Modify\ntab. Cuts out the manual <code>flyctl certs add</code> + hand-edited DNS dance\npreviously required per domain cutover.</li>\n<li>See screenshot below for how custom domain configuration can now be added to the modify stage of environment set up:</li>\n</ul>\n<p><img src=\"https://ngx-ramblers.org.uk/api/aws/s3/site-content/98732741-a8ae-4ec3-b112-7f819d45b267.jpeg\" alt=\"\"></p>\n<h2>Config model</h2>\n<ul>\n<li>New <code>CustomDomainStatus</code> enum (<code>pending</code> / <code>attached</code> / <code>failed</code>)\nand <code>CustomDomainEntry</code> interface (<code>hostname</code>, <code>addedAt</code>, <code>status</code>,\noptional <code>zoneId</code> and <code>message</code>).</li>\n<li><code>EnvironmentConfig</code> gains <code>customDomains?: CustomDomainEntry[]</code>,\npersisted on the environments config doc under each environment.</li>\n<li><code>ExistingEnvironment</code> carries the list out to the admin UI.</li>\n<li>New <code>CustomDomainResponse</code> for add/remove API calls.</li>\n</ul>\n<h2>Admin UI (Modify tab)</h2>\n<ul>\n<li>New &quot;Custom Domains&quot; panel on <code>environment-management.ts</code> with:</li>\n<li>Hostname input + Add Custom Domain button (disabled while any\noperation is busy).</li>\n<li>Per-domain table listing hostname, status badge\n(<code>attached</code>/<code>pending</code>/<code>failed</code>), added-at, and a Remove action.</li>\n<li>Inline error + scrollable session-log message stream wired through\nthe existing <code>SessionLogsComponent</code>.</li>\n<li><code>addCustomDomain</code> / <code>removeCustomDomain</code> client methods on\n<code>EnvironmentSetupService</code> hit the new routes.</li>\n<li>Resetting the Modify tab now also clears the custom-domain scratch\nstate.</li>\n<li><code>ManageAction</code> enum replaces stringly-typed <code>&#39;modify&#39;</code> / <code>&#39;destroy&#39;</code>\nvalues across the component.</li>\n<li>Environment empty-state now renders the &quot;No environments configured\nyet&quot; alert unconditionally when the list is empty, instead of showing\na spinner-in-dropdown during the initial fetch.</li>\n</ul>\n<h2>Server plumbing</h2>\n<ul>\n<li><code>server/lib/cli/commands/subdomain.ts</code> grows two exported functions:</li>\n<li><code>addCustomDomainForEnvironment(envName, hostname)</code></li>\n<li>validates the\nhostname (FQDN, no scheme, no path), loads env config, verifies the\nCloudflare token, resolves the zone via the new <code>zoneForHostname</code>,\nensures the Fly app has an IPv4 (allocates a shared one if missing),\nupserts A / AAAA records for the hostname, requests a Fly\ncertificate, reads back the issuance status, and persists a\n<code>CustomDomainEntry</code> on the environments config doc regardless of\noutcome so the UI shows <code>pending</code> / <code>attached</code> / <code>failed</code>.\nIdempotent: existing DNS records and certificates are detected and\nskipped.</li>\n<li><code>removeCustomDomainForEnvironment(envName, hostname)</code></li>\n<li>deletes\nmatching DNS records, removes the Fly certificate, and clears the\nentry from the config doc. Tolerant of missing zones and missing Fly\ntokens.</li>\n<li>Duplicate-hostname guard refuses to attach a hostname already claimed\nby another environment.</li>\n<li>Admin attach flow auto-updates <code>group.href</code> in system config when an\napex custom domain is attached, removing a previously-undiscoverable\nmanual step.</li>\n</ul>\n<h2>Cloudflare helpers</h2>\n<ul>\n<li><code>CloudflareZone</code> model type (id / name / status).</li>\n<li><code>listZones(apiToken, name?)</code> and <code>zoneForHostname(apiToken, hostname)</code>\nin <code>cloudflare-dns.ts</code>: walks the label chain\n(<code>sub.example.co.uk</code>, <code>example.co.uk</code>, <code>co.uk</code>) and resolves the\nfirst zone Cloudflare reports back under that exact name.</li>\n<li><code>CLOUDFLARE_API_BASE</code> + <code>cloudflareApi.*</code> helper centralises all\n<code>https://api.cloudflare.com/client/v4/...</code> URL construction; every\ncall site updated.</li>\n</ul>\n<h2>MX record handler zone-awareness</h2>\n<ul>\n<li><code>/cloudflare/email-routing/mx-records</code> GET and POST handlers now\nresolve the zone for the current <code>baseDomain</code> via <code>zoneForHostname</code>\nand operate on that zone instead of the default NGX zone. Previously,\ncustom-domain MX state was read from (and written to) the wrong zone,\nproducing false &quot;Missing MX records&quot; status and Cloudflare\n&quot;An identical record already exists&quot; rejections.</li>\n</ul>\n<h2>Routes</h2>\n<ul>\n<li><code>POST /environment-setup/add-custom-domain/:environmentName</code> and\n<code>POST /environment-setup/remove-custom-domain/:environmentName</code> behind\nthe existing <code>validateSetupAccess</code> guard. Errors are surfaced verbatim\nto the client (including <code>EnvironmentNotFoundError</code> -&gt; 404) so the\nsession-log panel shows the Cloudflare / Fly reason rather than a\ngeneric 500.</li>\n<li><code>GET /environment-setup/existing-environments</code> now includes\n<code>customDomains</code> on each environment so the UI can render the list.</li>\n</ul>\n<h2>Brevo domain authentication automation</h2>\n<ul>\n<li><code>authenticateSendingDomain</code> now accepts optional <code>cloudflareDnsConfig</code>\nand <code>baseDomainOverride</code>, so it can authenticate any domain in any\nzone - not only the NGX default. When no override is passed it\nresolves the correct zone via <code>zoneForHostname(apiToken, domainName)</code>\ninstead of defaulting to the configured NGX zone; the previous\ndefault caused DKIM/brevoCode TXT writes to collide in the wrong\nzone.</li>\n<li>New <code>ensureDkimAvailable</code> helper: when Brevo&#39;s\n<code>GET /senders/domains/configuration</code> returns no DKIM for a registered\ndomain, delete and re-register, then use the <code>POST /senders/domains</code>\nresponse body&#39;s <code>dnsRecords</code> directly (Brevo&#39;s own UI&#39;s &quot;Authenticate\nthe domain yourself&quot; shortcut). Eliminates the manual detour into\n<code>app.brevo.com</code> previously required for fresh custom domains.</li>\n<li>Refactored the inline try/catch <code>let</code> ladders in\n<code>authenticateSendingDomain</code> into <code>configureDnsRecords</code>,\n<code>requestAuthentication</code>, and <code>resolveCfAndBaseDomain</code> helpers that\nreturn typed results.</li>\n<li><code>brevoDomainsUrl</code> is now included on every non-authenticated outcome\n(including DNS-failure early returns), not just the &quot;missing DKIM&quot;\ncase, so admins always have an escape hatch to Brevo&#39;s UI.</li>\n<li><code>brevo/domains/config</code> route returns <code>response: null</code> with HTTP 200\nfor Brevo 404s instead of bubbling an error, so the frontend console\nand <code>MailService</code> logger stop spamming on the expected &quot;not\nregistered yet&quot; state.</li>\n</ul>\n<h2>Brevo sending-domain switch</h2>\n<ul>\n<li>New <code>POST /brevo/domains/switch</code> route + <code>switchBrevoSendingDomain</code>\norchestrator re-authenticate a target domain in Brevo, create the\nDKIM/SPF records in the correct Cloudflare zone, and mass-rewrite\nevery sender from <code>@oldDomain</code> to <code>@newDomain</code>. Reuses the existing\nsenders helpers (<code>listBrevoSenders</code>, <code>registerBrevoSender</code>,\n<code>deleteBrevoSenderById</code>) - no duplication of Brevo API code.</li>\n<li><code>listBrevoSenders</code> extracted to a shared helper; <code>deleteBrevoSenderById</code>\nexposed for reuse from the switch flow.</li>\n<li>Mail Senders tab surfaces a warning banner + &quot;Switch sending domain\nto <canonical>&quot; button when the Brevo-authenticated domain differs\nfrom <code>group.href</code>. Returned <code>logs[]</code> stream through\n<code>SessionLogsComponent</code>; on success, <code>baseDomain</code>, domain status, MX\nrecords and senders all reload.</li>\n</ul>\n<h2>Button-wrapper disabled-state fix</h2>\n<ul>\n<li><code>&lt;app-button-wrapper&gt;</code> no longer uses Bootstrap&#39;s <code>.disabled</code> class\n(which applies <code>pointer-events: none</code>) to render the disabled state;\nreplaced with a local <code>is-disabled</code> opacity-only class. Previously,\nclicks on visually-disabled buttons fell through the wrapper&#39;s\n<code>blockClick</code> handler and fired the caller&#39;s <code>(click)</code> binding anyway.\nAffects all 23 <code>&lt;app-brevo-button&gt;</code> callers across the app with\n<code>[disabled]</code> bindings.</li>\n</ul>\n<h2>Subdomain refactors</h2>\n<ul>\n<li><code>server/lib/cli/commands/subdomain.ts</code></li>\n<li>pulled the two remaining\n<code>let</code>-driven if/else ladders in the check flow into\n<code>reconcileCustomDomainDns</code> (does DNS reconciliation, returns a\n<code>dnsProblem | undefined</code>) and <code>classifyCertStatus</code> (pure function\nmapping <code>cert</code> + <code>dnsProblem</code> to <code>{ status, message }</code>).</li>\n</ul>\n"}