Configurable control-plane custom domain¶
Motivation¶
An operator created the DNS zone dashboard.elasticblast.com and wants the
control plane (the dashboard Container App)
to use it. The ElasticBLAST
OpenAPI sibling running on AKS webhooks
back to the dashboard (CONTROL_PLANE_URL); until now that URL was resolved only
from the DASHBOARD_PUBLIC_URL env or the auto-generated
*.azurecontainerapps.io FQDN — there was no UI to point it at a custom domain.
User-facing change¶
New Settings → Control plane domain section:
- Text field to enter the custom domain (e.g.
https://dashboard.elasticblast.com). Client- and server-side validation enforce the sibling's contract:https://only (exceptlocalhost), origin form, no path / query / fragment / credentials. The value is canonicalised (lower-cased scheme + host, rebuilt from the parsed components) and control characters are rejected, so a mixed-case scheme or a tab/newline injection can never survive into the stored webhook target. - Save domain persists the value durably (survives Container App revision restarts) — no redeploy needed. Clear reverts to the FQDN fallback.
- An Effective URL row shows what the next OpenAPI deploy will actually inject
and its
source(env/settings/container_app/none). When aDASHBOARD_PUBLIC_URLenv hard pin is present the section flags that it overrides the saved value.
The configured value is injected as CONTROL_PLANE_URL on the next OpenAPI
deploy (Settings → Public HTTPS / OpenAPI setup). It does not retro-patch a
running sibling.
Resolution precedence¶
api.services.control_plane_url.resolve_control_plane_url() (used by the OpenAPI
deploy task):
DASHBOARD_PUBLIC_URLenv — deploy-time hard pin (tests / private DNS).- Settings value — the new custom-domain field (durable singleton row).
CONTAINER_APP_NAME+CONTAINER_APP_ENV_DNS_SUFFIX— auto FQDN (default).""— sibling webhook disabled.
API / IaC diff summary¶
- New service
api/services/control_plane_url.py:normalise_control_plane_url,save_control_plane_url,get_control_plane_url,clear_control_plane_url,container_app_default_url,resolve_control_plane_url. Durable-only (dashboardsingletonsTable via the singleton store) — read at OpenAPI deploy time + Settings GET, so no Redis hot path. - New routes
GET/PUT/DELETE /api/settings/control-plane(api/routes/settings/control_plane.py), allrequire_caller-gated. PUT returns 503 when the durable store is unavailable so the SPA never shows a phantom save. api/tasks/openapi/deploy.py::_resolve_control_plane_urlnow delegates to the service (adds the Settings tier between env and the FQDN). Contract unchanged (still returnsstr).- SPA:
web/src/api/settings.tstyped client, newControlPlaneDomainSection.tsx, wired intoSettingsPanel.tsx+useSettingsPanel.tsxVALID_SECTIONS. - No Bicep change — the value lives in the durable Storage Table, not env.
Binding the domain to the Container App (separate one-time Azure step)¶
This change configures the URL string OpenAPI uses. Actually serving the
dashboard on dashboard.elasticblast.com (custom hostname + managed certificate
+ DNS records) is a separate Azure operation that cannot be validated locally —
run it once against the live deployment:
# 1. Add the hostname to the Container App (asserts domain ownership).
az containerapp hostname add \
--resource-group rg-elb-dashboard \
--name ca-elb-dashboard \
--hostname dashboard.elasticblast.com
# 2. Create the DNS records the validation needs in the dashboard.elasticblast.com zone:
# - CNAME dashboard -> <app FQDN> (or A -> ingress IP for an apex)
# - TXT asuid.dashboard -> <customDomainVerificationId>
az network dns record-set cname set-record \
--resource-group <dns-zone-rg> --zone-name elasticblast.com \
--record-set-name dashboard --cname ca-elb-dashboard.<env>.azurecontainerapps.io
# 3. Bind a managed certificate (free, auto-renewed).
az containerapp hostname bind \
--resource-group rg-elb-dashboard \
--name ca-elb-dashboard \
--hostname dashboard.elasticblast.com \
--environment <managed-env-name> \
--validation-method CNAME
After the cert is issued, set the same domain in Settings → Control plane domain so the OpenAPI sibling webhooks back to it.
Validation evidence¶
uv run pytest -q api/tests→ 3786 passed, 3 skipped (includes the newapi/tests/test_settings_control_plane.py: normalisation, save/get/clear, env→settings→container_app precedence, route 400/503/200 contracts).uv run ruff checkclean on all touched Python files.cd web && npm run buildclean;npm test -- --run→ 898 passed (includesuseSettingsPanelsection validation).