Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure API Management Custom Domain Terraform Module

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.

Terraform azurerm Module Type Resources Blast radius


🧩 Overview

  • 🌐 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_id field, 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 is timeouts only.

πŸ’‘ 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 destroy removes 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.


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


πŸ—ΊοΈ Where this fits in the family

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;
Loading

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.


🧬 What this module builds

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;
Loading

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.

βœ… Provider / Versions

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 hostnameConfigurations entirely, and write the service back. A host name on the service that is not listed here is removed.
  • Destroy sets hostnameConfigurations to null on the whole service β€” every custom host name goes, not only those this record created.
  • It collides with the api-management module's own hostname_configuration block. Two owners of one array means each apply reverts the other.
  • Every write waits for Succeeded six 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_id is force-new. Every host-name field updates in place.
  • certificate and key_vault_certificate_id are documented as mutually exclusive and are not enforced as such β€” there is no ConflictsWith, and the expand function sends both.
  • certificate is checked only for being non-empty, not for base64 β€” unlike azurerm_api_management_certificate.data, which does check it.
  • key_vault_certificate_id is 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_id maps to the same wire property and silently overrides key_vault_certificate_id. This module does not expose it.
  • default_ssl_binding is gateway-only, Optional and Computed β€” the provider's own comment is that Azure's logic for it cannot be predicted.
  • host_name is compared case-insensitively (the provider applies a case-difference diff suppression).
  • No tags, no location. The universal tail is timeouts only.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • Microsoft.ApiManagement/service/write and Microsoft.ApiManagement/service/read on 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.


Azure Prerequisites

  • 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-pkcs12 plus 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.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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.com is the only custom host name on the service. Any other custom host name that existed there is gone.


πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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_binding selects 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" }]
}

⚠️ portal is the legacy publisher portal; developer_portal is 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_certificate reports 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_source exists 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_module states 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 the api-management module: its hostname_configuration block 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.


πŸ“₯ Inputs

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.


🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin 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.


πŸ§ͺ Testing

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.


πŸ’¬ Example Output

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

πŸ” Troubleshooting

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

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."