Custom Domains¶
Custom domain setup is a three-stage process split across two Bicep templates (shared.bicep and main.bicep) and two workflows.
Architecture¶
shared.bicep → dna-shared-portfolio-westeurope-rg
└── Azure DNS Zone (sven-relijveld.com)
├── TXT asuid → prod CA env verification ID
├── TXT asuid.www → prod CA env verification ID
├── TXT asuid.dev → dev CA env verification ID
├── CNAME www → prod frontend Container App FQDN
├── CNAME dev → dev frontend Container App FQDN
├── CNAME docs → prod SWA default hostname
└── CNAME dev-docs → dev SWA default hostname
main.bicep → dna-{env}-portfolio-{region}-rg
└── Container Apps Environment
├── Managed Certificate www.sven-relijveld.com [prod, SniEnabled]
└── Managed Certificate dev.sven-relijveld.com [dev, SniEnabled]
The Three-Stage Process¶
Azure Container Apps requires a hostname to be registered in the environment before a managed TLS certificate can be issued.
Stage 1 — No binding¶
custom_domain is empty (or deploy_custom_domain=false, customDomains: []).
This is the initial state. All infra is deployed without any custom domain configuration.
Stage 2 — Disabled binding (hostname registration)¶
custom_domain is set, deploy_custom_domain=false → bindingType: 'Disabled'.
The hostname is registered in the Container Apps Environment so Azure can validate DNS, but no certificate is issued yet. DNS records must exist at this point.
Stage 3 — SniEnabled (managed TLS cert active)¶
deploy_custom_domain=true → bindingType: 'SniEnabled'.
Azure issues a managed certificate and attaches it. Both parameters.prod.bicepparam and parameters.dev.bicepparam are at Stage 3.
Deploy order matters
deploy-infrastructure.yml always overrides deploy_custom_domain=false for the base infra step — cert issuance only activates via deploy-application.yml or a manual dispatch with deploy_containers=true.
Workflow: deploy-infrastructure-shared.yml¶
Triggers: push to main touching infra/shared.bicep, infra/modules/dns_zone.bicep, infra/modules/app_service_domain.bicep, infra/parameters.shared.bicepparam, or the workflow file; workflow_dispatch
What it does:
- Discover prod values — queries prod CAE
customDomainVerificationIdand frontend FQDN viaaz rest(ARM API) - Discover dev + SWA values — queries dev CAE verification ID, dev frontend FQDN, prod SWA default hostname, dev SWA default hostname
- Deploys
shared.bicepwith all discovered values as parameters - Creates DNS records conditionally — each record is only created if the discovered value is non-empty
- If
purchase_domain: true: fetches TLD agreement keys, determines runner public IP, validates domain availability, purchases domain
Discovery steps emit empty strings gracefully when target resources don't exist yet — no hard failures.
Duration: ~3–5 minutes
Step-by-Step: Setting Up Custom Domains from Scratch¶
Step 1: Deploy base infrastructure (prod)¶
Push to main or run:
This creates the Container Apps Environment. The verification ID and frontend FQDN are now discoverable.
Step 2: Run shared infrastructure workflow¶
This creates the DNS zone and all DNS records (TXT + CNAME) for prod. For dev, records are only created after dev infra exists.
Step 3: Purchase the domain (first time only)¶
In infra/parameters.shared.bicepparam:
Commit and push to main (or workflow_dispatch). NS delegation is automatic — no registrar changes needed.
Step 4: Enable managed cert (prod)¶
In infra/parameters.prod.bicepparam:
Commit and push. The next deploy-application.yml run will issue the managed cert.
Step 5: Enable SWA custom domain (docs)¶
In infra/parameters.prod.bicepparam:
Commit and push. The SWA custom domain uses CNAME delegation — no cert to issue separately.
Static Web App Custom Domains¶
SWA custom domains (docs.sven-relijveld.com, dev-docs.sven-relijveld.com) use CNAME delegation rather than managed certs. The DNS CNAME records point to the SWA default hostname, which is discovered at runtime by the shared workflow.
| Domain | Parameter | Status |
|---|---|---|
docs.sven-relijveld.com |
deploy_swa_custom_domain: true in prod params |
Active |
dev-docs.sven-relijveld.com |
deploy_swa_custom_domain: true in dev params |
Active |
Sensitive Registrant Fields¶
parameters.shared.bicepparam stores only non-sensitive registrant fields (name, email, country). The following are injected at workflow runtime from GitHub Secrets:
| GitHub Secret | Field |
|---|---|
CONTACT_PHONE |
Phone number |
CONTACT_ADDRESS1 |
Street address |
CONTACT_CITY |
City |
CONTACT_STATE |
State/province |
CONTACT_POSTAL_CODE |
Postal code |
This keeps PII out of git history.
Current Domain Status¶
| Domain | Type | Status |
|---|---|---|
www.sven-relijveld.com |
Container App (prod) | Active — SniEnabled managed cert |
dev.sven-relijveld.com |
Container App (dev) | Active — SniEnabled managed cert |
docs.sven-relijveld.com |
Static Web App (prod) | Active — CNAME delegation |
dev-docs.sven-relijveld.com |
Static Web App (dev) | Active — CNAME delegation |
Troubleshooting¶
DNS records not resolving¶
Run the shared infrastructure workflow to ensure all DNS records exist:
Then verify:
Managed cert not issued¶
Ensure Stage 2 has completed (hostname registered with Disabled binding) before attempting Stage 3. The DNS TXT record (asuid.www) must resolve to the Container Apps Environment verification ID.
bindingType: 'Disabled' still triggers validation¶
This is expected — Disabled still registers the hostname in the environment and triggers Azure's TXT record validation. It does not issue a cert. Only SniEnabled issues a cert.
Next Steps¶
- OIDC & Secrets — GitHub Secrets required for the shared workflow
- Resource Overview — DNS zone resource map
- Bicep Structure —
shared.bicepandcontainer_apps.bicepimplementation
Page history
| Field | Value |
|---|---|
| Last updated | 2026-03-10 |
Changelog
| Date | PRs | Summary |
|---|---|---|
| 2026-03-10 | #66 | Extracted custom domains page from workflow-execution-order; 3-stage cert process, shared.bicep, DNS records |