Replacement Ordering: create_before_destroy and the New-Before-Old Contract
Some changes cannot be applied in place: changing an EC2 instance's instance type requires a new instance, replacing a database's engine version means a new database. When a plan shows -/+ must be replaced, Terraform's default ordering is destroy, then create — safe for names and quotas, but with an outage window proportional to the rebuild. The lifecycle meta-argument rewrites that contract:
resource "aws_launch_template" "app" {
name_prefix = "app-"
image_id = var.ami_id
instance_type = "t3.medium"
lifecycle {
create_before_destroy = true
}
}
With create_before_destroy = true, apply builds the replacement first and removes the old object only after the new one succeeds — the zero-downtime pattern for anything fronting traffic, and the reason name_prefix appears alongside it. That pairing is not stylistic: many cloud resources require globally unique names or ARNs, so a create-before-destroy of an identically-named object collides with the still-existing original ("AlreadyExists" mid-apply, with both objects half-reconciled). name_prefix-style naming — where the provider generates a unique suffix and allows up to 90 days of overlap — is the escape hatch, and recognizing when a resource needs it is part of reading a replacement plan carefully.
Two propagation facts make this an advanced topic. First, create_before_destroy propagates to the resource's dependencies: because resource A depends on resource B, enabling the flag on A implicitly enables it for B as well (and Terraform records that in state), so a whole replacement chain keeps new-before-old ordering — and you cannot later force it back to false on the dependency, since that would imply a cycle in the graph. The result can be brief over-provisioning (old and new coexisting) rather than under-provisioning, which is the trade most availability-sensitive systems prefer. Two related details bite in practice: destroy-time provisioners do not run on a resource with create_before_destroy enabled (the destroy they skip is not the event they were written for), and the flag is scoped per resource — neighboring resources in the same plan still follow default destroy-first ordering unless they carry the flag too. So the pattern in real configurations is a cluster of lifecycle blocks along the replacement path, added deliberately and reviewed as availability decisions rather than pasted wholesale. One more constraint applies to every lifecycle setting you will meet in this topic: the values must be literals — lifecycle rules are consumed while Terraform builds the graph, before arbitrary expressions can be evaluated, so create_before_destroy = var.zero_downtime is rejected outright.
Watch for the inverse trap as well: on resources where creation is slow and failure-prone (big RDS instances, for example), create-before-destroy doubles the risk window and the cost of a failed apply — you now have two expensive objects and a state record pointing at neither cleanly. The judgment call is the same one every flag here demands: decide per resource whether "never a gap" or "never a surprise cost" is the actual requirement, and encode the answer where the next engineer will read it. Note also what makes the plan propose the replacement in the first place: for most resources, providers mark specific arguments as forcing new objects (instance type, engine version, disk encryption), so a lifecycle block never triggers a replacement on its own — it only reorders one the diff already demands.
prevent_destroy: An Error Message You Want Before the One You Do Not
The most absolute lifecycle flag is also the simplest to reason about:
resource "aws_db_instance" "orders" {
identifier = "orders-prod"
engine = "postgres"
instance_class = "db.r5.large"
# ...
lifecycle {
prevent_destroy = true
}
}
With prevent_destroy set, no apply that would delete this object can proceed — not a narrowed terraform destroy -target, not a full destroy, not a replacement that happens to pass through deletion. The plan stops with an error naming the resource and the setting:
Error: Instance cannot be destroyed
on main.tf line 42, in resource "aws_db_instance" "orders":
42: lifecycle {
Resource aws_db_instance.orders has lifecycle option prevent_destroy set to true,
but the plan calls for this resource to be destroyed.
Read what that error really is: a plan-time check, before any delete API is touched — which is precisely what makes it valuable on the objects where a bad plan becomes an irreversible incident. Production databases, stateful sets, the bucket holding everyone's artifacts, the DNS zone for the company domain: on these, the realistic threats are not careful engineers following procedure but a mistyped terraform destroy at 11 p.m., a module refactor that renames a resource without a state migration, or a count-to-for_each conversion like the one from the expressions phase. prevent_destroy catches all three with one line and one plan.
The guard's scope has edges worth mapping before you rely on it. It vetoes any planned destruction of the managed object — a full terraform destroy, a -targeted teardown, and a replacement whose plan passes through deletion (so a ForceNew change on a protected resource fails until the guard is removed — plan the interaction when changing immutable attributes of protected objects). What it cannot see is a plan that never mentions the resource: deleting another stack's configuration that references the protected object still destroys that other stack, and deleting the protected resource's own block removes the guard with it, since the flag lives in configuration rather than in state. The guard is therefore a seatbelt on your own destructive paths, not a quarantine around the object — provider-side controls (deletion policies, retention configurations, account-level protections) remain the last line of defense for assets whose loss is truly unacceptable.
The removal path is deliberately two-step, and that friction is the feature: you must edit the configuration to remove or relax the flag, apply that change, and only then can the destroy proceed. No flag flip and delete in one motion — the object's protection dies in a reviewed pull request where someone can ask why. Two subtleties from the official documentation complete the picture, and both are interview-grade. The flag is not recorded in state — lifecycle rules live in configuration — so if you delete the resource's block entirely, the next apply destroys the object anyway: the guard disappears with the block that carried it, which is why refactors that "clean up" a file deserve the same scrutiny as a destroy. And prevent_destroy blocks deletion but does not block in-place updates, so a database with the guard still happily applies parameter changes; it is a seatbelt on the destructive path, not a force field around the object. Use it sparingly, on the objects whose loss would be an incident.
ignore_changes and replace_triggered_by: Living With Other Actors
Two flags complete the lifecycle toolkit, and both exist for the same uncomfortable truth: your resources have co-tenants — autoscalers, agents, humans with console access — that write to the same objects your state tracks.
ignore_changes tells Terraform to stop diffing the listed attributes after creation:
resource "aws_autoscaling_group" "app" {
name = "app-asg"
desired_capacity = 3
launch_template { ... }
lifecycle {
ignore_changes = [desired_capacity, tags["Name"]]
}
}
The canonical example is right there: an autoscaling group whose desired capacity is genuinely owned by a scaling policy at runtime. Without the flag, every plan shows Terraform wanting to "fix" the scaled-down value back to the configured one — a diff fight between two legitimate managers. The semantics to state precisely: Terraform considers the listed arguments when planning a create, but ignores them when planning an update — creation reads configuration exactly as before, and only update planning stops proposing the attribute's return. Two scoping rules keep it predictable: entries are attribute addresses, so map and list elements are addressable with index notation (tags["Name"], root_block_device[0].volume_size); and only resource-type attributes can be ignored — never the meta-arguments themselves or ignore_changes in its own list. The blunt form, ignore_changes = [all], ignores every attribute: Terraform may still create and destroy the object, but it will never propose an update to it — rare, and worth a review argument every time it appears. One honest limitation to name: because the arguments are still used at creation, a variable that drives an ignored attribute only takes effect on rebuilds, not on in-place updates — plan for that when the attribute matters.
replace_triggered_by solves the adjacent problem: an attribute that is technically stable but whose meaning changed, so in-place updating is wrong and only a rebuild is honest:
resource "aws_lambda_function" "etl" {
function_name = "etl"
source_code_hash = filebase64sha256("etl.zip")
lifecycle {
replace_triggered_by = [
aws_s3_bucket_object.etl_bundle.version_id, # new bundle version -> rebuild
terraform_data.subnets_changed.output, # forced trigger value (1.4+)
]
}
}
When any referenced value changes, the plan proposes replacement even though nothing in the function's own body moved — the standard answer for "the subnet list changed and this resource must be rebuilt but no single attribute of it did", and terraform_data (with its triggers_replace input) is the purpose-built object for manufacturing such a trigger value. Together, the four flags form one coherent idea you can now state completely: create_before_destroy governs ordering of replacements, prevent_destroy vetoes deletions at plan time, ignore_changes cedes ownership of specific attributes after creation, and replace_triggered_by ties replacement to values outside the resource's own arguments. Each is a negotiated boundary with reality, each shows up in plan output when it matters, and each is the sort of detail that separates "has operated Terraform" from "has read about it" in an interview. When you inherit a configuration containing them, read the flags as a record of past negotiations: an ignore_changes entry is a fossil of some other actor's ownership, a prevent_destroy marks something someone was once burned by, and a replace_triggered_by chain maps a rebuild dependency that exists nowhere in the arguments. Understanding what each fossil means is the difference between copying lifecycle blocks between resources — a common and dangerous habit, since none of these decisions transfers between objects with different criticality — and applying them where the actual requirement lives. That completes this phase's tour of resources; next comes parameterization: Input Variable Declarations.
Quiz
1. A resource must be replaced and has create_before_destroy = true. What is the operation order, and what naming caveat applies?
2. What does prevent_destroy block, and how is it removed?
3. What is the precise semantics of ignore_changes = [desired_capacity] on an autoscaling group?
4. A subnets list changes and your Lambda must be rebuilt, though none of its own arguments changed. Which mechanism expresses this?
Flashcards
Question
When does Terraform propose replacement instead of an in-place update, and what does create_before_destroy change?
Click to reveal answer
Answer
When the provider cannot apply the change in place (ForceNew attributes — instance type, engine version — or name/ID-affecting changes). Default order is destroy-then-create (a gap); `create_before_destroy = true` builds the replacement first and deletes the old one after, cascading through dependents that must also be replaced — pair with `name_prefix` where names must be unique.
Question
What is the scope and effect of prevent_destroy?
Click to reveal answer
Answer
A per-resource veto on deletion, checked at plan time: any plan whose actions include destroying the object — targeted or full, including replacements that pass through destroy — aborts with an error naming the resource. In-place updates still work. Removal is a two-step: unset the flag in configuration, apply, then destroy.
Question
How does ignore_changes behave at creation versus update, and what is the classic use case?
Click to reveal answer
Answer
Listed arguments are still used when planning a create; update planning simply never proposes restoring them, so out-of-band values persist unchallenged. Classic case: an autoscaling group's `desired_capacity` owned by a scaling policy. Entries are attribute addresses (index notation like `tags["Name"]` works), the `all` keyword ignores everything, and only resource attributes — never meta-arguments — can be ignored.
Question
What does replace_triggered_by do, and what is terraform_data's role in it?
Click to reveal answer
Answer
It proposes replacement whenever any referenced value changes, even when the resource's own arguments are stable — the answer to "a shared list changed and this object must be rebuilt". `terraform_data` (with `triggers_replace`) is a state-only object for manufacturing such trigger values when no natural referenced value exists.
Question
Organize the four lifecycle flags by the question they answer.
Click to reveal answer
Answer
Ordering of replacement → create_before_destroy. Veto deletion → prevent_destroy (plan-time, two-step removal). Cede attribute ownership after creation → ignore_changes (create-time values still come from config). Tie replacement to external values → replace_triggered_by. All are per-resource, visible in plan behavior, and are availability/cost decisions rather than style.
Revision Notes
Key Takeaways
- 1. Replacement is provider-decided (ForceNew attributes, ID changes); `create_before_destroy` converts the outage gap into brief coexistence and cascades to dependents that must also rebuild — pair with `name_prefix` where names collide
- 2. `prevent_destroy` is a plan-time veto on every deletion path, released only through a configuration change and its own apply — the seatbelt on the destructive path, not a force field around updates
- 3. `ignore_changes` cedes *update* ownership of attributes (classic: ASG `desired_capacity`) while creation still reads configuration — so a driving variable only takes effect on rebuilds, never in-place
- 4. `replace_triggered_by` rebuilds a resource when external values change; `terraform_data` with `triggers_replace` is the manufactured trigger for "no attribute changed but it must rebuild"
- 5. All four are per-resource negotiated boundaries with reality; each is an availability-or-cost decision that belongs in review with its reason attached
Interview Tips
- • "How do you get zero-downtime replacements?" — create_before_destroy plus provider-supported unique naming (name_prefix); explain the cascade through dependents and the brief over-provisioning trade-off
- • "Walk me through protecting the production database." — prevent_destroy on the resource; know the error is plan-time, that targeted destroys are equally blocked, and that release is a reviewed config change plus its own apply — never a state hack
- • "The ASG plan keeps reverting capacity our autoscaler set. Fix?" — ignore_changes on `desired_capacity` (and specific `tags["Name"]` entries as needed); add the precise semantics to show depth — creation still reads configuration, only update planning stops proposing the revert
- • "A shared subnet list changed and this resource must be rebuilt although its arguments did not." — replace_triggered_by referencing the list's value, or terraform_data with triggers_replace to manufacture the trigger; contrast with depends_on, which orders but never triggers replacement
Cheat Sheet
The lifecycle Meta-argument
resource "aws_launch_template" "app" {
name_prefix = "app-" # unique-name partner of create_before_destroy
lifecycle {
create_before_destroy = true # build replacement before removing old (cascades to dependents)
prevent_destroy = true # plan-time veto on any deletion; unset flag + apply to release
ignore_changes = [desired_capacity, tags] # after create, stop diffing these
replace_triggered_by = [terraform_data.rebuild.output] # rebuild when referenced value changes
}
}
| Flag | Governs | Key caveat |
|---|---|---|
create_before_destroy |
replacement ordering | unique-name collisions → use name_prefix |
prevent_destroy |
deletion | blocks legit teardowns too; two-step release |
ignore_changes |
update diffs | create still reads config — a driving variable only affects rebuilds |
replace_triggered_by |
external triggers | terraform_data.triggers_replace manufactures the trigger value |
Reading a plan: -/+ must be replaced = default destroy-then-create; with CBD the plan narrates create-then-destroy. Lifecycle decisions are per-resource availability/cost trade-offs — review them like code.