Hedronite · Cert Lesson · Terraform Associate 004 · Objective 4f · Fri 2026-10-23

create_before_destroy for Terraform Associate 004 replacement order, dependency propagation, silenced destroy provisioners, and unique names

-/+ destroys first, +/- creates first. Creating first trades a gap for a collision.

Lesson Class: Cert (HashiCorp Terraform Associate 004 · Objective 4f Define resource dependencies)
Tested with: Terraform v1.14.3 (terraform_data + local-exec, no provider)
Lab: lab/order, cbd, chain, literal, names · run.sh · out/cert-lab.txt
Source: HashiCorp exam content list · Terraform lifecycle, provisioner, graph, depends_on and terraform_data docs · Brikman
Read the Symbol
-/+ is delete then create; +/- is create then delete.
It Spreads and Sticks
Dependencies inherit create_before_destroy, and state records it.
Two Must Fit
Old and new exist together, so names must differ.
The old server waited for the new one. Its cleanup never came.

The problem

A team has to change an argument that Terraform cannot update in place, on a resource that other things point at. Terraform calls this a replacement. Brikman's example in Terraform: Up and Running is a launch configuration (a template describing how to build servers) referenced by an Auto Scaling group (ASG, a set of servers kept at a target size). Launch configurations are immutable, so any change forces a replacement. By default Terraform destroys the old object first, which leaves a gap. In Brikman's case the destroy cannot even complete, because the ASG still references the old launch configuration.

The fix is create_before_destroy = true, and three surprises follow. A dependency nobody marked starts being replaced in the same order. A cleanup step written as a destroy-time provisioner (a command attached to a resource that runs when the resource is destroyed) stops running. An object with a fixed name fails to create, because the old object still holds the name.

This is exam objective 4f, "Define resource dependencies in configuration", whose new 004 sub-topic is "depends_on and create_before_destroy lifecycle rules". Questions describe a replacement and ask which order Terraform uses, which plan symbol appears, and what else the setting changes. The lab behind this lesson used the built-in terraform_data resource and local shell commands as stand-ins for real infrastructure.

The idea, in plain words

Terraform builds a dependency graph from references between resources and from depends_on. When an object must be replaced, Terraform splits the work into a destroy step and a create step, because the destroy order is often different from the create order. By default the destroy step runs first. create_before_destroy flips that order for one resource. Because the new object must exist before the old ones go away, Terraform applies the same order to everything that resource depends on, and it records that choice in state (the file where Terraform keeps track of the objects it manages). The price is that both objects exist for a while, so their names must not collide, and destroy-time provisioners do not run. Terraform leaves the setting opt-in for exactly those reasons. The point to carry: creating first trades a gap for a collision, so the question to ask before turning it on is whether two copies can exist at once.

Replacement order and how to read it in a plan

A replacement happens when an argument changes that cannot be updated in place. The documentation says Terraform "destroys and re-creates resources whose arguments have changed but that Terraform cannot update in-place because of remote API limitations" (API means application programming interface). The lab uses terraform_data, a resource from the built-in provider terraform.io/builtin/terraform, so no provider needs configuring. It stores input and copies it to output, and "Terraform replaces the resource when the value changes" for triggers_replace. A destroy-time provisioner sets when = destroy, and "Destroy provisioners run before the resource is destroyed."

variable "rev" {
  type    = number
  default = 1
}

resource "terraform_data" "server" {
  triggers_replace = var.rev
  input            = "server-rev-${var.rev}"

  provisioner "local-exec" {
    command = "echo \"create ${self.input}\" >> events.log"
  }
  provisioner "local-exec" {
    when    = destroy
    command = "echo \"destroy ${self.output}\" >> events.log"
  }
}

Changing rev changes triggers_replace, which forces a replacement. A local-exec provisioner runs a shell command on the machine running Terraform. The first one appends a line to events.log on creation. The second, marked when = destroy, appends a line on destruction, and it reads self.output because provisioners cannot name their parent resource and use the special self object instead. The log therefore records which steps ran and in what order, which makes the order visible.

The lab applied rev = 1, then planned rev = 2:

  # terraform_data.server must be replaced
-/+ resource "terraform_data" "server" {
      ~ input            = "server-rev-1" -> "server-rev-2"
      ~ triggers_replace = 1 -> 2
    }

Plan: 1 to add, 0 to change, 1 to destroy.
{"address":"terraform_data.server","actions":["delete","create"]}
terraform_data.server: Destroying... [id=1cb7c765-b23e-6522-d44e-2f2352a686c5]
terraform_data.server: Destruction complete after 0s
terraform_data.server: Creating...
--- events.log
create server-rev-1
destroy server-rev-1
create server-rev-2

The plan symbol -/+ reads as "minus, then plus": destroy, then create. The JSON from terraform show -json (JSON is JavaScript Object Notation) agrees with ["delete","create"], and the apply lines show Destroying before Creating. The log confirms it, with the destroy provisioner firing between the two creates. Between "Destruction complete" and "Creating..." no object existed at all, which is the gap from the problem section.

Adding a lifecycle block containing create_before_destroy = true to the same resource changed everything else about the run:

+/- resource "terraform_data" "server" {
{"address":"terraform_data.server","actions":["create","delete"]}
terraform_data.server: Creating...
terraform_data.server (deposed object fc89c6a8): Destroying... [id=8ef35574-cab5-b6c1-bc6b-2c46c55adb27]
--- events.log
create server-rev-1
create server-rev-2

The symbol flipped to +/- and the actions to ["create","delete"]. "Deposed" is how the output labels the old object: it is kept aside while its replacement is created, then destroyed under that label. The short hex after it (fc89c6a8) names the deposed copy, while the id= is the object's own ID from the first apply.

SettingPlan symbolJSON actionsApply orderDestroy provisioner ran
Default-/+["delete","create"]Destroying, then CreatingYes
create_before_destroy = true+/-["create","delete"]Creating, then deposed DestroyingNo

Symptom: A plan shows +/- on a resource whose configuration has no lifecycle block at all.

Cause: A resource that depends on it has create_before_destroy, and Terraform propagates the setting (next section).

Fix: Read the dependents, or the state, before assuming the plan is wrong.

Propagation to dependencies, and where it is recorded

A dependency, here, is an edge in the graph. Terraform infers most of them from expression references: "References to resource attributes are turned into dependencies from the resource with the interpolation to the resource being referenced." depends_on exists for "hidden resource or module dependencies that Terraform cannot automatically infer", it "cannot include arbitrary expressions", and it should be "a last resort because it can cause Terraform to create more conservative plans that replace more resources than necessary". Prefer references.

The documentation states the propagation rule with an example: "create_before_destroy is enabled on resource A but not on resource B. Because resource A is dependent on resource B, Terraform enables create_before_destroy for resource B implicitly by default and stores it to the state file. As a result, you cannot override create_before_destroy to false on resource B because that would imply dependency cycles in the graph."

resource "terraform_data" "template" {
  triggers_replace = var.template_rev
  input            = "template-rev-${var.template_rev}"
}

resource "terraform_data" "server" {
  triggers_replace = terraform_data.template.id
  input            = "server-from-${terraform_data.template.output}"

  lifecycle {
    create_before_destroy = true
  }
}

Here template has no lifecycle block. server makes two references to it: triggers_replace = terraform_data.template.id means a new template ID forces a new server, and input copies the template's output into the server's name. Those references are the only link between the two, and they are enough for Terraform to infer that server depends on template, with no depends_on written. Only server sets the rule. State after the first apply, read with jq, a command-line JSON reader:

{"name":"server","create_before_destroy":true}
{"name":"template","create_before_destroy":true}

Both instances carry true, though template never set it. In the order example's state, the same field read "create_before_destroy":null. This matches the documentation: "Except for create_before_destroy, Terraform does not explicitly record a resource's lifecycle rule to state." After template_rev=2, both resources showed +/- with "Plan: 2 to add, 0 to change, 2 to destroy." The apply order was:

terraform_data.template: Creating...
terraform_data.server: Creating...
terraform_data.server (deposed object 995184c0): Destroying...
terraform_data.template (deposed object df332aa4): Destroying...

The new template exists first, the new server is built from it, the old server goes next, and the old template goes last. This is the split the graph documentation describes: resource nodes being destroyed "are split into two: one node that destroys the resource and another that creates the resource", because "the destroy order is often different from the create order". Destroying the template last means nothing is left pointing at a deleted object.

Symptom: A teammate adds create_before_destroy = false to the dependency to stop it from replacing early, and nothing changes.

Cause: The setting is forced on dependencies, because the opposite would imply a dependency cycle in the graph.

Fix: If the dependency truly cannot coexist with its replacement, remove create_before_destroy from the dependent, or redesign the names (last section). The lab did not test adding = false on the dependency, so this rests on the documentation alone.

What create_before_destroy turns off, and what lifecycle accepts

With the rule enabled, Terraform skips destroy-time provisioners. The provisioner documentation says "Enabling the create_before_destroy argument on the resource prevents destroy-time provisioners from running", and the lifecycle page says the same. The lab's second run proves it: the plan destroyed the deposed object, yet events.log holds only create server-rev-1 and create server-rev-2, with no destroy line. The apply still succeeded, so nothing flags the skipped step.

The second restriction is about values. Lifecycle settings affect how the graph is built, so "only literal values can be used because the processing happens too early for arbitrary expression evaluation." The lab tried create_before_destroy = var.cbd and terraform init failed with exit 1:

Error: Variables not allowed

  on main.tf line 11, in resource "terraform_data" "server":
  11:     create_before_destroy = var.cbd

Variables may not be used here.
...
Unsuitable value: value must be known
[init exit 1]

Note where it failed: terraform init, before any plan, so the mistake surfaces on the first command anyone runs. The second error, "value must be known", says the same thing from the other side: the setting needs an answer fixed in the file. Write true or false directly.

Symptom: After adding create_before_destroy, a cleanup step that removed the old object from a list (a destroy-time provisioner) silently stops, and stale entries pile up while applies succeed.

Cause: Destroy-time provisioners do not run when the rule is enabled.

Fix: Move cleanup into the provider's own resource or a separate step. The documentation also advises using destroy-time provisioners "sparingly and with care". The stale web-1 entry in the next section is this exact failure.

Unique names, and failure mid-replacement

The documentation calls the setting opt-in "because many remote object types have unique name requirements or other constraints that must be accommodated for both a new and an old object to exist concurrently." It adds that "Some resource types offer special options to append a random suffix onto each object name to avoid collisions", and that Terraform "cannot automatically activate such features". Brikman's zero-downtime recipe takes the other route: the ASG name depends on the launch configuration name, so each change produces a new name and a new ASG, created before the original is destroyed.

resource "terraform_data" "service" {
  triggers_replace = var.rev
  input            = var.name

  provisioner "local-exec" {
    command = "mkdir registry/${self.input}"
  }

  lifecycle {
    create_before_destroy = true
  }
}

The directory registry/ stands in for an API that rejects duplicate names, because mkdir fails when the directory exists. input = var.name is the object's name, and the creation-time provisioner registers it by making the directory. triggers_replace = var.rev lets the lab force a replacement without touching the name, which is exactly what happens when some other argument changes. The destroy-time cleanup is omitted because it would never run under this rule. With the default name = "web", the first apply left registry: web. Changing rev to 2 fails:

terraform_data.service (local-exec): mkdir: registry/web: File exists
Error: local-exec provisioner error
[apply exit 1]
--- state list after the failed apply
terraform_data.service
  # terraform_data.service is tainted, so must be replaced
  # terraform_data.service (deposed object 76741145) will be destroyed
Plan: 1 to add, 0 to change, 2 to destroy.

The new object collided with the old one's name. The creation-time provisioner failed, so Terraform marked the new object tainted, meaning marked for replacement, "so that it can destroy and recreate the tainted resource on the next terraform apply". The old object was never destroyed. Note what terraform state list shows: one address, terraform_data.service, even though state holds two objects. The deposed one appears only in the next plan, which is why it reads "2 to destroy" for a single resource: the tainted new object and the deposed old one. A failed create left the old object in place, which is the safety benefit of creating first. The next apply would collide again, because the name still has not changed.

A per-revision name fixes the collision. With name=web-1 and then rev=2 -var name=web-2, the apply exited 0 with "Creating..." then "(deposed object b72f8b41): Destroying...". The registry then read web-1 web-2. The old name remained because no destroy provisioner ran.

Symptom: An apply fails on create with a name-already-exists error, only after create_before_destroy was added, and the following plan shows "2 to destroy" for one resource.

Cause: The old and new objects must coexist.

Fix: Derive the name from what changes, or use a provider's suffix or prefix option where one exists. Brikman cautions that this approach has limits and favors native, first-class features where the platform has them.

Putting it together

StatementTrue or falseSource
Default replacement creates the new object firstFalseLifecycle reference
+/- means create, then destroyTrueLab step 2
A dependency can opt out with = falseFalseLifecycle reference
Destroy-time provisioners run under the ruleFalseProvisioner docs
create_before_destroy = var.x is acceptedFalseLab step 4

Verified locally with Terraform 1.14.3; the exam tests 1.12, and the rules here come from current documentation. Not tested: any real provider or cloud API, create_before_destroy = false on a dependency, depends_on itself (the lab used references only), and -parallelism.

Check yourself

Question 1
A plan shows -/+ for a resource. What order does apply use?
tap to reveal
Destroy the old object, then create the replacement. +/- would mean create first.
Question 2
Resource A sets create_before_destroy = true and references resource B, which sets nothing. B's argument changes and forces replacement. What happens?
tap to reveal
Terraform replaces B in create-first order too, and stores create_before_destroy in state for B.
Question 3
After enabling create_before_destroy, a destroy-time provisioner that removes an entry from a list no longer runs, and apply reports success. Why?
tap to reveal
Destroy-time provisioners do not run when the setting is enabled, so the cleanup is skipped without an error.
Question 4
Why does create_before_destroy = var.enabled fail?
tap to reveal
Lifecycle settings are processed before expressions can be evaluated, so only literal values are allowed.
Question 5
A replacement under create_before_destroy fails because the new object's fixed name already exists. What state is left?
tap to reveal
The new object is tainted and the old object remains as a deposed object, so the next plan replaces the tainted one and destroys the deposed one ("2 to destroy" for one resource).

Sources