Create_before_destroy reverses destroy ordering in core, but not with azurerm_cdn_frontdoor_custom_domain

Versions: Terraform 1.13.3 (HCP Terraform agent) and 1.13.5 (local); hashicorp/azurerm 4.59.0.

(I did try it with Terraform 1.16.1 with no success)

The situation

A security policy resource references a for_each map of custom domains through a whole-resource reference in a dynamic block. Azure refuses to delete a custom domain while a security policy still references it, so removing a key from the map requires the policy’s in-place update to land before the orphaned domain’s destroy.

Terraform schedules them the other way round, because the policy’s update node waits on every instance node of the referenced resource — including the orphan being destroyed:

"terraform_data.policy" -> "terraform_data.domain[\"b\"] (destroy)"     # A -> B means A waits for B

create_before_destroy on the domain reverses that edge, which is the documented behaviour and is what part 1 below demonstrates. It does not take effect against the real provider, which is what I’m trying to understand.


Part 1 — provider-free repro (works as documented)

No credentials needed; uses the built-in terraform_data.

variable "keys" { type = list(string) }

resource "terraform_data" "domain" {

for_each = toset(var.keys)

input = each.key

lifecycle {

create_before_destroy = true # remove this to see the other ordering

}

}

resource "terraform_data" "policy" {

input = [for k, v in terraform_data.domain : v.output]

}

terraform init

terraform apply -auto-approve -var='keys=["a","b"]'

terraform apply -auto-approve -var='keys=["a"]'

With create_before_destroy:

terraform_data.policy: Modifying...

terraform_data.domain["b"]: Destroying...

Without it:

terraform_data.domain["b"]: Destroying...

terraform_data.policy: Modifying...

So core reverses the destroy edge exactly as expected. This holds under every variant I tried: the resources nested inside a module, count on the policy, and sibling resources that also reference the domain and are destroyed in the same apply.


Part 2 — the same shape with azurerm (CBD has no effect)

Requires an Azure subscription and a DNS zone you control. The custom domains do not need to finish certificate validation to reproduce.

provider "azurerm" {

features {}

}

variable "domains" {

type = list(string)

default = ["one", "two"]

}

variable "zone_name" {

type = string # e.g. "example.com" - a zone you own

}

resource "azurerm_resource_group" "this" {

name = "rg-afd-cbd-repro"

location = "eastus"

}

resource "azurerm_cdn_frontdoor_profile" "this" {

name = "afd-cbd-repro"

resource_group_name = azurerm_resource_group.this.name

sku_name = "Premium_AzureFrontDoor" # Premium required for security policies

}

resource "azurerm_cdn_frontdoor_firewall_policy" "this" {

name = "afdcbdrepowaf"

resource_group_name = azurerm_resource_group.this.name

sku_name = azurerm_cdn_frontdoor_profile.this.sku_name

enabled = true

mode = "Detection"

}

resource "azurerm_cdn_frontdoor_custom_domain" "this" {

for_each = toset(var.domains)

name = each.key

cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id

host_name = "${each.key}.${var.zone_name}"

tls {

certificate_type = "ManagedCertificate"

}

lifecycle {

create_before_destroy = true

}

}

resource "azurerm_cdn_frontdoor_security_policy" "this" {

name = "repro-security-policy"

cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id

security_policies {

firewall {

cdn_frontdoor_firewall_policy_id = azurerm_cdn_frontdoor_firewall_policy.this.id

association {

patterns_to_match = ["/*"]

dynamic "domain" {

for_each = azurerm_cdn_frontdoor_custom_domain.this

content {

cdn_frontdoor_domain_id = domain.value.id

}

}

}

}

}

}

terraform apply                              # creates both domains, both in the policy

terraform apply -var='domains=["one"]' # remove "two"

Expected (per part 1): the security policy is modified first, then the domain is destroyed.

Actual: the security policy is never modified at all, the domain destroy is attempted first, and Azure rejects it:

Error: deleting Front Door Custom Domain: (Custom Domain Name "two" / Profile Name "afd-cbd-repro"

/ Resource Group "rg-afd-cbd-repro"): cdn.AFDCustomDomainsClient#Delete: Failure sending request:

StatusCode=400 -- Original Error: Code="BadRequest"

Message="Resource is associated with security policy 'repro-security-policy'."

Confirmed after the failed apply that the domain is still listed in the security policy’s associations, i.e. the update genuinely did not run rather than running and not taking effect.


The question

Why does create_before_destroy reverse the destroy edge in part 1 but appear to have no effect in part 2? The plan is identical either way — a lifecycle block produces no plan diff — so there is no way to tell from a plan whether it is in effect.

Things already ruled out

Attempt Result
Referencing instances individually, custom_domain[k].id references resolve at resource level; identical edge
Dropping the reference, building domain IDs as strings edge is rebuilt from the dependency recorded in state
terraform apply -target=<security policy> targeting pulls in the target’s dependencies, so the orphan destroy comes too
depends_on added to the policy duplicates the existing reference edge; no change
depends_on inverted (domain depends on policy, reference broken) still destroys first, and breaks creation — the policy would be created referencing domains that do not exist yet
Terraform version nothing in the 1.14, 1.15 or 1.16 changelogs touches destroy ordering except the 1.16.0 regression fixed in 1.16.1, which postdates 1.13.3

Hi @quinn.wilson,

(note that it makes your post much harder to read and copy when you put every line of config in a separate code block)

There is no reason I can see why your azurerm example would work differently. Terraform does not differentiate between a terraform_data resource and a azurerm_cdn_frontdoor_custom_domain resource, all resources are ordered identically regardless of type.

What is the plan proposed by the provider when you remove the domain? You said that the security policy is not changed at all, which implies there is no change planned for the azurerm_cdn_frontdoor_security_policy. If so, that is a provider bug where it’s ignoring the config change for some reason.

(note that it makes your post much harder to read and copy when you put every line of config in a separate code block)

Noted.

What is the plan proposed by the provider when you remove the domain?

The plan says that it’s going to:
delete the custom domain
update the security policy - removing the custom domain from the security_policies.firewall.association list.

Which looks right, the problem is it tries to do it in the wrong order.

You said that the security policy is not changed at all, which implies there is no change planned for the azurerm_cdn_frontdoor_security_policy. If so, that is a provider bug where it’s ignoring the config change for some reason.

It had in fact planned to modify the security policy, but it tried to delete the custom domain first, ran into a hard fail and then never modified the security policy.
I guess it does seem like a provider bug,

Interesting. If the plan said the azurerm_cdn_frontdoor_security_policy will be updated, and azurerm_cdn_frontdoor_custom_domain is create_before_destroy, I don’t see why it would not happen in the correct order.

I don’t have Azure access, but directly translating your config into a test example also works correctly. If you want you can run the plan change on your example with

TF_LOG_CORE=trace terraform plan -var='domains=["one"]'

and the very last graph output after a Completed graph transform: line will be the proposed apply graph. That will show the exact ordering and not depend on watching apply for changes where provider errors might interfere.

In that output you should see something like

azurerm_cdn_frontdoor_custom_domain.this["two"] (destroy) - *terraform.NodeDestroyResourceInstance
  azurerm_cdn_frontdoor_security_policy.this - *terraform.NodeApplyableResourceInstance

which means the that azurerm_cdn_frontdoor_custom_domain will not be destroyed until after the azurerm_cdn_frontdoor_security_policy update is complete.

Solved — and it was my test methodology, not Terraform. Thanks for the TF_LOG_CORE=trace
pointer, that’s what cracked it.

The apply graph did have it backwards:

security_policy.this[0] -> custom_domain["tftest3"] (destroy)

but the reason was in the ForcedCBDTransformer lines just above it:

ForcedCBDTransformer: "...custom_domain["tftest3"] (destroy)"
  (*terraform.NodeDestroyResourceInstance) has no CBD descendant, so skipping
ForcedCBDTransformer: "...custom_domain (expand)"
  (*terraform.nodeExpandApplyableResource) already has create_before_destroy set

The resource has the flag. The orphaned destroy instance node doesn’t.

create_before_destroy is recorded per instance in state, not read from configuration
when an orphan is destroyed — which makes sense: an instance whose for_each key is gone has
no config entry left to read, so the destroy node uses whatever was stamped at that instance’s
last apply.

Every test I ran added the lifecycle block and removed the map key in the same change. The
instance had been created while the config had no block, so its state flag was false and the
destroy node was skipped. The configuration was right; the state wasn’t.

Minimal reproduction of the trap:

variable "keys" { type = list(string) }

resource "terraform_data" "domain" {
  for_each = toset(var.keys)
  input    = each.key

  lifecycle {
    create_before_destroy = true
  }
}

resource "terraform_data" "policy" {
  input = [for k, v in terraform_data.domain : v.output]
}

Thanks for you help, it was much appreciated!