Owns the custom host names on an Azure API Management service β gateway, portal, developer portal, management and SCM β and the certificate each presents. Targets
hashicorp/azurerm ~> 4.0.
- π Owns the custom host names on an API Management service across all five endpoint types.
- π Attaches a certificate to each host name, from Key Vault (preferred) or an inline base-64 PFX.
- 𧨠States the blast radius up front: this record is a singleton, it is authoritative over the service's whole hostname array, and destroying it strips every custom host name from the service.
- β Refuses to expose the deprecated
key_vault_idfield, which maps to the same wire property and silently overrides its replacement. - β±οΈ Explains why an apply here takes minutes β the provider waits for six consecutive healthy reads, before and after the write.
- π·οΈ Carries no
tagsβ the resource has none. The universal tail istimeoutsonly.
π‘ Why it matters: this is not an ordinary child resource. It has no Azure object of its own β it is a projection onto the parent service β so an ordinary-looking
terraform destroyremoves every custom host name from the service, including ones this configuration never created. That consequence is invisible in a destroy plan, which shows a single resource going away.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart TB
rg["terraform-azurerm-resource-group"]
kv["terraform-azurerm-key-vault"]
uai["terraform-azurerm-user-assigned-identity"]
ra["terraform-azurerm-role-assignments"]
dns["terraform-azurerm-dns-zone"]
apim["terraform-azurerm-api-management"]
this["terraform-azurerm-api-management-custom-domain"]
cert["terraform-azurerm-api-management-certificate"]
gw["terraform-azurerm-api-management-gateway"]
rg -->|"name"| apim
apim -->|"id"| this
kv -->|"secret identifier, versionless to follow the vault"| this
uai -->|"client_id"| this
uai -->|"principal_id"| ra
ra -->|"Key Vault Secrets User on the vault"| kv
dns -->|"CNAME or A record, not created here"| this
apim -->|"its own hostname_configuration block writes the SAME property"| this
cert -->|"separate store, used by the self-hosted gateway"| gw
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef keystone fill:#004578,stroke:#002b4d,color:#ffffff;
classDef sib fill:#eef3f8,stroke:#b9c7d6,color:#1b2a3a;
class this me;
class apim keystone;
class rg,kv,uai,ra,dns,cert,gw sib;
The API Management family is large, so this diagram names only this module's direct neighbours rather than every api-management-* module in the library. Note the edge from the service module back to this one: api-management exposes its own hostname_configuration block against the same service property, so the two are alternative owners of one array rather than complements. Note also what is not an input: DNS. Nothing here creates the record that makes a host name resolve.
flowchart TB
subgraph inputs["Inputs"]
svc["api_management_id, the only force-new field"]
gw["gateway entries, plus default_ssl_binding"]
other["portal, developer_portal, management, scm entries"]
end
this["azurerm_api_management_custom_domain.this"]
prop["the service hostnameConfigurations property, replaced whole on every apply"]
subgraph outputs["Outputs"]
ohn["all_host_names, host_name_count_by_endpoint"]
ocert["certificate_expiry_by_host_name, gateway_certificate_details"]
opost["host_names_using_key_vault, host_names_with_an_inline_certificate"]
ofact["destroying_this_removes_all_custom_host_names, this_resource_is_authoritative_over_service_host_names"]
end
svc --> this
gw -->|"at least one of the five must be non-empty"| this
other -->|"at least one of the five must be non-empty"| this
this -->|"read, replace, write back"| prop
this --> ohn
this --> ocert
this --> opost
this --> ofact
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef keystone fill:#004578,stroke:#002b4d,color:#ffffff;
classDef sib fill:#eef3f8,stroke:#b9c7d6,color:#1b2a3a;
class this me;
class prop keystone;
class svc,gw,other,ohn,ocert,opost,ofact sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_api_management_custom_domain.this |
1 | The keystone, and the only resource. One per service β the Resource ID ends in the literal default. Renders up to five endpoint blocks, each a list. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module β the caller configures provider "azurerm" { features {} }, auth and subscription |
Schema notes that bite
- It is a singleton. The Resource ID ends with the literal segment
default; there is one per service and no Azure resource behind it. - It is authoritative. Create and update read the whole service, replace
hostnameConfigurationsentirely, and write the service back. A host name on the service that is not listed here is removed. - Destroy sets
hostnameConfigurationsto null on the whole service β every custom host name goes, not only those this record created. - It collides with the
api-managementmodule's ownhostname_configurationblock. Two owners of one array means each apply reverts the other. - Every write waits for
Succeededsix consecutive times at one-minute intervals, before and after the write β and delete does the same. The provider's own timeouts here are 60 minutes rather than the 30 used elsewhere in this family. - Only
api_management_idis force-new. Every host-name field updates in place. certificateandkey_vault_certificate_idare documented as mutually exclusive and are not enforced as such β there is noConflictsWith, and the expand function sends both.certificateis checked only for being non-empty, not for base64 β unlikeazurerm_api_management_certificate.data, which does check it.key_vault_certificate_idis Optional and Computed on the 4.x line, and its validator accepts any Key Vault item type. The 5.0 line restricts it to/secrets/and drops the Computed flag.- The deprecated
key_vault_idmaps to the same wire property and silently overrideskey_vault_certificate_id. This module does not expose it. default_ssl_bindingis gateway-only, Optional and Computed β the provider's own comment is that Azure's logic for it cannot be predicted.host_nameis compared case-insensitively (the provider applies a case-difference diff suppression).- No
tags, nolocation. The universal tail istimeoutsonly.
Microsoft.ApiManagement/service/writeandMicrosoft.ApiManagement/service/readon the API Management service. This is broader than the family's other child modules need, and unavoidably so: the resource has no sub-resource of its own, so writing a host name is a write against the whole service. API Management Service Contributor on the service is the smallest built-in role that covers it.- No Key Vault permission is needed by the principal running Terraform. The vault is read at runtime by the API Management service's identity, not by the plan.
The identity that reads the vault is a separate principal, and this module grants it nothing:
- Key Vault Secrets User on the vault (RBAC), or an access policy granting GET and LIST on secrets, for the service's system-assigned identity or the user-assigned identity named in
ssl_keyvault_identity_client_id. Microsoft's own pages disagree on the access-policy form -- the custom-domain guidance asks for GET and LIST, the troubleshooting article for GET alone -- so grant both, which the RBAC role already does.
π Reading this resource returns the certificate metadata Azure computed β expiry, subject, thumbprint β and never the inline certificate or its password, which Azure does not return at all. Plan access here is not certificate-material access.
- An existing API Management service, and no other owner of its host names.
- DNS records pointing each configured host name at the service. Nothing here creates them, and without them the configuration is valid and unreachable.
- A certificate per host name: a Key Vault secret of type
application/x-pkcs12plus an identity with GET and LIST on secrets, or a base-64 PFX supplied inline. Azure's own requirements for the certificate itself: exported as PFX, encrypted with triple DES, a private key of at least 2048 bits, and the full chain including intermediates. - An API Management SKU that offers the endpoints being configured β the Consumption tier does not offer the full set.
- The caller configures
provider "azurerm" { features {} }, authentication and subscription.
terraform-azurerm-api-management-custom-domain/
βββ providers.tf # required_version + pinned azurerm; no provider block
βββ variables.tf # five endpoint lists, deeply typed; every shape enforced at plan time
βββ main.tf # the single keystone resource and its five dynamic blocks
βββ outputs.tf # id first, then host names, certificate metadata, and the blast-radius facts
βββ README.md # this document
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore # the canonical library ignore set
provider "azurerm" {
features {}
}
module "apim_custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
}]
}βΉοΈ The caller configures the provider, including the mandatory
features {}block. This module declares none.
β οΈ This call is authoritative: after it applies,api.contoso.comis the only custom host name on the service. Any other custom host name that existed there is gone.
Consumes
| Input | Type | Source |
|---|---|---|
api_management_id |
string |
terraform-azurerm-api-management output id |
<endpoint>[*].key_vault_certificate_id |
string |
a secret's data-plane identifier in the vault from terraform-azurerm-key-vault |
<endpoint>[*].ssl_keyvault_identity_client_id |
string |
terraform-azurerm-user-assigned-identity output client_id |
<endpoint>[*].certificate / .certificate_password |
string |
provisioned out of band β never committed |
Emits
| Output | Consumed by |
|---|---|
all_host_names |
terraform-azurerm-dns-zone, which must publish a record for each |
certificate_expiry_by_host_name |
alerting, which has to live outside Terraform |
host_names_pinned_to_a_key_vault_version |
rotation review |
destroying_this_removes_all_custom_host_names |
destroy planning and management-lock decisions |
1 Β· Minimal gateway host name
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
}]
}π No certificate material passes through Terraform. The service's system-assigned identity fetches the secret and needs GET on the vault.
2 Β· Gateway with a user-assigned identity and default SSL binding
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
ssl_keyvault_identity_client_id = module.apim_identity.client_id
default_ssl_binding = true
}]
}βΉοΈ
default_ssl_bindingselects the certificate served to a client that sends no SNI header. Leaving it unset does not mean false β the field is Computed and Azure decides, which is why setting it deliberately on exactly one entry is worth doing.
3 Β· Several gateway host names
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [
{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
default_ssl_binding = true
},
{
host_name = "api-eu.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-eu-contoso-com"
},
]
}
β οΈ Each endpoint block is a list, so an endpoint may answer on several names. Remember the list is authoritative: dropping an entry here removes that host name from the service.
4 Β· All five endpoints
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{ host_name = "api.contoso.com", key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api" }]
developer_portal = [{ host_name = "developer.contoso.com", key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/developer" }]
portal = [{ host_name = "portal.contoso.com", key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/portal" }]
management = [{ host_name = "mgmt.contoso.com", key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/mgmt" }]
scm = [{ host_name = "scm.contoso.com", key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/scm" }]
}
β οΈ portalis the legacy publisher portal;developer_portalis the current one. They are separate endpoints with separate host-name types, and configuring a name on the wrong one applies cleanly and serves nothing at the address you expected.
5 Β· Inline PFX instead of Key Vault
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
certificate = filebase64("${path.module}/certs/api-contoso-com.pfx")
certificate_password = var.pfx_password
}]
}π This puts private key material and its password into Terraform state in plaintext, and Azure never returns either value.
host_names_with_an_inline_certificatereports every host name in this position.
β οΈ The provider checks this field only for being non-empty β not even for base64. This module adds that check because a malformed value would otherwise reach Azure.
6 Β· Requiring client certificates on the gateway
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
negotiate_client_certificate = true
}]
}π Negotiating a client certificate decides which certificates are presented, never which caller is authorized β that still needs a policy that checks the subject or thumbprint. Setting it on a browser-facing portal endpoint prompts every visiting browser, which is rarely the intent.
7 Β· Restricting the administrative endpoints
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api"
}]
management = [{
host_name = "mgmt.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/mgmt"
negotiate_client_certificate = true
}]
scm = [{
host_name = "scm.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/scm"
}]
}
β οΈ The management and SCM endpoints are administrative surfaces β SCM serves the service's configuration repository. A custom host name is branding, not access control: this resource applies none. Restrict them at the network layer.
8 Β· Adopting an existing configuration
# key_vault_certificate_id is Optional AND Computed on the 4.x line, so an
# entry with no certificate source adopts whatever Azure already holds.
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{ host_name = "api.contoso.com" }]
}terraform import 'module.custom_domain.azurerm_api_management_custom_domain.this' \
"/subscriptions/$SUB/resourceGroups/rg-platform-apim/providers/Microsoft.ApiManagement/service/apim-platform-prod/customDomains/default"
β οΈ List every host name the service already has before applying. This resource is authoritative, so the first apply removes any host name missing from the configuration βhost_names_with_no_certificate_sourceexists to make an adopting entry visible for what it is.
9 Β· Custom timeouts
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
}]
timeouts = {
create = "90m"
update = "90m"
delete = "90m"
read = "10m"
}
}
β οΈ Raise these, do not lower them. The provider defaults to 60 minutes here because every write waits for six consecutive healthy reads a minute apart, twice. Shortening them is the most likely way to turn a slow-but-healthy change into a timed-out one that leaves the service mid-update.
10 Β· Protecting against an accidental destroy
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
}]
}
# A lifecycle block cannot be added to a module call, so the protection goes
# on the SERVICE. A CanNotDelete lock prevents deletion, not replacement.
resource "azurerm_management_lock" "apim" {
name = "apim-no-delete"
scope = module.apim.id
lock_level = "CanNotDelete"
notes = "Deleting the service, or the custom-domain record, strips every custom host name."
}π Destroying this record sets the service's whole hostname array to null. A destroy plan shows one resource going away and says nothing about the host names that go with it.
11 Β· Reviewing certificate posture across an estate
output "host_names_that_will_not_rotate" {
value = {
for k, m in module.custom_domains : k => m.host_names_pinned_to_a_key_vault_version
if length(m.host_names_pinned_to_a_key_vault_version) > 0
}
}
output "private_keys_in_state" {
value = {
for k, m in module.custom_domains : k => m.host_names_with_an_inline_certificate
if length(m.host_names_with_an_inline_certificate) > 0
}
}
output "expiry_calendar" {
value = { for k, m in module.custom_domains : k => m.certificate_expiry_by_host_name }
}π‘ The third output is the input to whatever does the alerting. Nothing in Terraform, the provider or the resource renews a certificate β a Key Vault-sourced one is refreshed by Azure only when its identifier carries no version.
12 Β· Publishing the DNS records
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://kv-platform.vault.azure.net/secrets/api-contoso-com"
}]
}
module "public_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dns-zone.git?ref=v1.0.0"
name = "contoso.com"
resource_group_name = module.rg.name
cname_records = {
api = {
name = "api"
record = trimprefix(module.apim.gateway_url, "https://")
}
}
}
β οΈ Without this record the host name is configured, the certificate is valid, the apply succeeded, and nothing reaches the gateway.dns_is_not_configured_by_this_modulestates it as a constant so it survives in every instance's output.
13 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-platform-apim"
location = "eastus2"
}
module "apim_identity" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
name = "id-apim-tls"
resource_group_name = module.rg.name
location = module.rg.location
}
module "vault" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-platform-tls"
resource_group_name = module.rg.name
location = module.rg.location
tenant_id = var.tenant_id
}
# The identity must be able to read the secret before any host name is created.
module "vault_roles" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.vault.id
role_assignments = {
apim_reads_tls_secret = {
role_definition_name = "Key Vault Secrets User"
principal_id = module.apim_identity.principal_id
}
}
}
module "apim" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management.git?ref=v1.0.0"
name = "apim-platform-prod"
resource_group_name = module.rg.name
location = module.rg.location
publisher_name = "Platform Engineering"
publisher_email = "platform-engineering@example.com"
sku_name = "Developer_1"
identity = {
type = "UserAssigned"
identity_ids = [module.apim_identity.id]
}
# Host names are owned by the custom-domain module below, so this block is
# deliberately left unset. Setting both makes each apply revert the other.
}
module "custom_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-custom-domain.git?ref=v1.0.0"
api_management_id = module.apim.id
gateway = [{
host_name = "api.contoso.com"
key_vault_certificate_id = "https://${module.vault.name}.vault.azure.net/secrets/api-contoso-com"
ssl_keyvault_identity_client_id = module.apim_identity.client_id
default_ssl_binding = true
}]
developer_portal = [{
host_name = "developer.contoso.com"
key_vault_certificate_id = "https://${module.vault.name}.vault.azure.net/secrets/developer-contoso-com"
ssl_keyvault_identity_client_id = module.apim_identity.client_id
}]
}
module "public_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dns-zone.git?ref=v1.0.0"
name = "contoso.com"
resource_group_name = module.rg.name
cname_records = {
api = {
name = "api"
record = trimprefix(module.apim.gateway_url, "https://")
}
developer = {
name = "developer"
record = trimprefix(module.apim.developer_portal_url, "https://")
}
}
}π No certificate material passes through Terraform. The vault holds the keys, a user-assigned identity reads them, and the role assignment that makes the read work is explicit rather than assumed.
β οΈ The vault secrets are not created here. Importing a PFX into the vault through Terraform would put the private key back into state, which is the thing this composition exists to avoid.
β οΈ Note the comment on theapi-managementmodule: itshostname_configurationblock is left unset on purpose. Both it and this module write the same service property, so having two owners means each apply reverts the other.
| Group | Variables |
|---|---|
| Identity (force-new) | api_management_id |
| Endpoint host names (at least one non-empty) | gateway, portal, developer_portal, management, scm |
| Universal tail | timeouts |
Full input schemas
api_management_id = string # force-new; the SERVICE id, not its name, and not this record's own id
# All five endpoints share this entry shape; only `gateway` adds default_ssl_binding.
gateway = optional(list(object({
host_name = string # bare DNS name, no scheme and no port
key_vault_certificate_id = optional(string) # data-plane URL; omit the version to follow the vault
certificate = optional(string) # sensitive at the provider; base64 PFX
certificate_password = optional(string) # sensitive at the provider
negotiate_client_certificate = optional(bool, false)
ssl_keyvault_identity_client_id = optional(string) # GUID; null selects the system-assigned identity
default_ssl_binding = optional(bool) # gateway only; Computed, so Azure decides when unset
})), [])
portal = optional(list(object({ ... })), []) # same shape, no default_ssl_binding
developer_portal = optional(list(object({ ... })), [])
management = optional(list(object({ ... })), [])
scm = optional(list(object({ ... })), [])
timeouts = optional(object({ create, read, update, delete }))The deprecated key_vault_id field is deliberately not accepted β see the_deprecated_key_vault_id_field_is_not_exposed. See variables.tf for the full descriptions and every validation.
| Output | Description | Notes |
|---|---|---|
id |
Resource ID of the custom-domain record | Emitted first; ends in the literal default |
api_management_id |
Resource ID of the parent service | The only force-new input |
api_management_name |
Name of the parent service | Derived from the ID |
resource_group_name |
Resource group holding the parent service | Derived from the ID |
gateway_host_names |
Host names the gateway answers on | |
portal_host_names |
Host names the legacy publisher portal answers on | |
developer_portal_host_names |
Host names the developer portal answers on | |
management_host_names |
Host names the management endpoint answers on | |
scm_host_names |
Host names the SCM endpoint answers on | |
all_host_names |
Every custom host name, sorted | The authoritative set |
host_name_count_by_endpoint |
How many host names each endpoint carries | |
gateway_certificate_details |
Per-host expiry, subject, thumbprint, source, status | Public metadata |
certificate_expiry_by_host_name |
Every host name mapped to its certificate's expiry | Nothing here renews them |
default_ssl_binding_host_names |
Gateway hosts carrying the default SSL binding | Azure's choice when unset |
host_names_using_key_vault |
Hosts whose certificate comes from the vault | Derived |
host_names_with_an_inline_certificate |
Hosts whose private key is in Terraform state | Derived |
host_names_with_both_certificate_sources |
Hosts supplying both, which the provider permits | Derived; reported, not refused |
host_names_with_no_certificate_source |
Hosts supplying neither; legal only when adopting | Derived |
host_names_pinned_to_a_key_vault_version |
Hosts whose certificate will not follow the vault | Derived |
host_names_negotiating_client_certificates |
Hosts requesting a client certificate | Derived |
gateway_entries_using_the_default_azure_host |
Gateway entries naming the default Azure host | Derived; public-cloud suffix only |
this_record_is_a_singleton_per_service |
Constant true | |
this_resource_is_authoritative_over_service_host_names |
Constant true | |
destroying_this_removes_all_custom_host_names |
Constant true | The blast radius |
conflicts_with_the_api_management_module_hostname_configuration |
Constant true | |
an_apply_here_takes_minutes_by_construction |
Constant true | |
the_deprecated_key_vault_id_field_is_not_exposed |
Constant true | |
certificate_material_is_never_read_back |
Constant true | |
nothing_here_checks_a_certificate_against_its_host_name |
Constant true | |
dns_is_not_configured_by_this_module |
Constant true | |
force_new_fields |
The single force-new field | |
this_resource_supports_no_azure_resource_tags |
Constant true |
No certificate material is emitted. Expiry, subject and thumbprint are public metadata and deliberately not marked sensitive β redacting them would break plan review while protecting nothing.
This is not an ordinary child resource. There is no Azure object called a custom domain. The provider builds a Resource ID ending in the literal default and uses it to address the parent service's hostnameConfigurations property. Everything unusual about the resource follows from that: it is a singleton, it is authoritative, it needs service/write rather than a narrower permission, and it collides with anything else that writes the same property.
Authoritative means what it says. Every apply reads the whole service, replaces the entire hostname array with what is configured here, and writes the service back. There is no merge and no partial update. A host name added by hand between applies is removed by the next one, silently and without appearing as a change to anything but this resource.
Destroy is wider than the resource. The delete path sets hostnameConfigurations to null on the whole service β not only the entries this record created. Anything added by a script, by hand, or by the api-management module's own hostname_configuration block goes with it, and a destroy plan shows one resource being removed. Where that matters, put a CanNotDelete management lock on the service; a lifecycle block cannot be added to a module call.
Do not manage host names in two places. The api-management module writes the same property. Two owners means each apply reverts the other, both plans look correct in isolation, and the symptom is a host name that keeps disappearing.
Slowness here is structural, not a symptom. Every write waits for the service to report Succeeded six consecutive times at one-minute intervals β before the write and again after β and delete does the same. Twelve minutes is a normal apply. That is why the provider's timeouts here are 60 minutes and why shortening them is the wrong instinct.
The two certificate sources are not exclusive in code. The documentation calls them mutually exclusive; the schema declares no ConflictsWith, and the expand function sends both when both are set. This module reports the combination rather than refusing it, because the provider accepts it and a validation failure would block terraform destroy as well as apply.
One field is deliberately missing. The 4.x line still carries a deprecated key_vault_id on every entry. It maps to the same wire property as key_vault_certificate_id, overrides it when both are set, and is removed in the next major line. There is no configuration it makes possible, so this module does not accept it.
Configuring a host name does not publish it. No DNS record is created here. Until a CNAME or A record points at the service, the host name is configured, the certificate is valid, the apply succeeded, and nothing arrives.
| Concern | Default in this module | Opt-out |
|---|---|---|
| Client-certificate negotiation | negotiate_client_certificate = false on every endpoint, matching the provider |
set it true per entry |
| Certificate source | not defaulted β every example uses Key Vault, and host_names_with_an_inline_certificate reports the alternative |
supply certificate and certificate_password |
| Vault rotation | not defaulted; host_names_pinned_to_a_key_vault_version reports entries that will not follow the vault |
pin a version deliberately |
Deprecated key_vault_id |
not exposed β it maps to the same wire property, overrides its replacement, and is removed in the next major line | none; use key_vault_certificate_id |
| Empty configuration | refused at plan time, because applying one would strip every custom host name from the service | none |
| Documented-but-unenforced rules | reported through outputs rather than refused, since the provider accepts them and a validation failure blocks destroy |
none |
| Secret inputs | the provider marks certificate and certificate_password sensitive, which redacts plan output and does not encrypt state |
none β use Key Vault, or an encrypted backend |
| Public metadata | expiry, subject and thumbprint are deliberately not sensitive | none |
| Tagging | no tags variable β the resource exposes none |
tag the parent API Management service |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module by immutable tag β ?ref=v1.0.0 β never a branch. This module is plan-only from the library's point of view: a human applies from CI against a reviewed plan. Budget the pipeline timeout generously; see an_apply_here_takes_minutes_by_construction.
Where each check actually fires, which is not one answer. Run from this module's own directory as the Runbook does, terraform validate evaluates no variables at all and therefore fires none of the checks below; it proves the configuration parses and is type-correct, and nothing more. On a calling configuration that supplies values, it fires the single-variable checks: the service-ID shape (including the mistake of passing this record's own /default ID) and, per entry, the host-name shape, the base64 and PEM checks on an inline certificate, the Key Vault identifier shape and the identity GUID. The at-least-one-endpoint rule is the exception: its condition reads all five endpoint variables, and Terraform skips a cross-variable condition at validate, so that one fires during variable evaluation at plan.
terraform console -no-color -var-file=<file> with an immediate end-of-input fires every one of them, including the cross-variable rule, and needs no credentials -- which is what makes it the harness to use here, since terraform plan against this provider does not run without them. Drive both a fully populated and a minimal fixture: a fully populated one is what catches a check that is wrong in the accepting direction, and Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof a check is missing.
What only a real plan or apply reaches: whether the service exists, whether the SKU offers the endpoint, whether the identity can read the vault, and whether the certificate is genuinely a PFX. What nothing reaches, at any stage: whether the certificate covers the host name, whether DNS resolves it, and what else was on the service's hostname array before this configuration replaced it.
id = ".../providers/Microsoft.ApiManagement/service/apim-platform-prod/customDomains/default"
api_management_id = ".../providers/Microsoft.ApiManagement/service/apim-platform-prod"
api_management_name = "apim-platform-prod"
resource_group_name = "rg-platform-apim"
gateway_host_names = ["api.contoso.com"]
developer_portal_host_names = ["developer.contoso.com"]
all_host_names = [
"api.contoso.com",
"developer.contoso.com",
]
host_name_count_by_endpoint = {
"developer_portal" = 1
"gateway" = 1
"management" = 0
"portal" = 0
"scm" = 0
}
certificate_expiry_by_host_name = {
"api.contoso.com" = "2027-04-18T23:59:59Z"
"developer.contoso.com" = "2027-04-18T23:59:59Z"
}
default_ssl_binding_host_names = ["api.contoso.com"]
host_names_using_key_vault = [
"api.contoso.com",
"developer.contoso.com",
]
host_names_with_an_inline_certificate = []
host_names_pinned_to_a_key_vault_version = []
force_new_fields = ["api_management_id"]
destroying_this_removes_all_custom_host_names = true
| Symptom | Cause | Fix |
|---|---|---|
| A host name disappeared from the service after an unrelated apply | This resource is authoritative and replaces the whole hostname array; the missing name was not in the configuration | Add it here. Every custom host name on the service belongs in this one configuration |
A terraform destroy removed every custom host name, not just one |
The delete path sets hostnameConfigurations to null on the whole service |
Expected, and stated by destroying_this_removes_all_custom_host_names. Protect the service with a CanNotDelete lock |
| A host name keeps reverting between applies | Both this module and the api-management module's hostname_configuration block are managing the same property |
Pick one owner and leave the other unset |
| Apply times out after an hour | Each write waits for six consecutive healthy service reads a minute apart, twice; a service already mid-update never settles | Raise the timeouts, and confirm nothing else is updating the service concurrently |
| Plan is clean, the host name does not resolve | No DNS record points at the service β nothing here creates one | Publish a CNAME or A record. See example 12 |
| TLS fails at request time on a configuration that applied cleanly | The certificate's subject or SANs do not cover the host name; nothing checks that pairing | Compare the subject values in gateway_certificate_details against the configured host names |
| The vault rotated the certificate and the endpoint serves the old one | The key_vault_certificate_id carries a version segment, which pins it permanently |
Remove the version. host_names_pinned_to_a_key_vault_version reports it |
| Apply fails fetching the certificate from Key Vault | The service has no identity, the named identity is not attached to it, or it lacks GET on secrets | Add the identity block on the api-management module; grant Key Vault Secrets User on the vault |
| Both a certificate and a Key Vault ID were set and the result is unclear | The provider declares no ConflictsWith and sends both; Azure decides |
Resolve to one source. host_names_with_both_certificate_sources reports it |
A configuration using key_vault_id will not migrate cleanly |
This module does not accept the deprecated field, which overrides its replacement and is removed in the next major line | Move the value to key_vault_certificate_id |
azurerm_api_management_custom_domainβ provider resource reference- Configure a custom domain name for Azure API Management β endpoints, certificates and DNS requirements
- Use managed identities in Azure API Management β the identity that reads a Key Vault certificate
- Sibling modules:
terraform-azurerm-api-management,terraform-azurerm-api-management-certificate,terraform-azurerm-api-management-gateway,terraform-azurerm-key-vault,terraform-azurerm-user-assigned-identity,terraform-azurerm-role-assignments,terraform-azurerm-dns-zone - This module's
SCOPE.mdβ the cross-module contract, permissions and prerequisites
π "Infrastructure as Code should be standardized, consistent, and secure."