Terraform Basics #7 Resource Lifecycle Control: Replacement Conditions, create_before_destroy, prevent_destroy
In #2 we met the -/+ symbol in a plan. It meant destroy and recreate, and at the time the only advice was “always stop and check.” This post tackles that replacement head-on: which changes trigger it, how to reduce downtime when it is unavoidable, and how to stop replacements and deletions from happening in the first place. All the tools live in a single nested block called lifecycle. From the moment a production database comes under Terraform management, this post’s content is your entire safety net — which makes it arguably the most practical part of the basics series.
The provider decides what forces a replacement #
Even within a single resource, changes carry different weight depending on the attribute. On an EC2 instance, changing instance_type is handled as an update (strictly, stop-then-modify), but changing the AMI is a replacement — you cannot swap out the operating system image of a running server. Which attributes force a replacement is defined by the provider in the resource schema, and there is no need to memorize it. The plan tells you.
# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
~ ami = "ami-0abc..." -> "ami-0def..." # forces replacement
}The attribute annotated with # forces replacement is the cause. Building the habit of scanning plan output for that annotation is the starting point of this post. If the resource being replaced is a stateless web server, you can accept it — but if it holds data, you need the tools that follow.
create_before_destroy: flip the order, remove the gap #
The default replacement order is destroy, then create. Between the old one disappearing and the new one coming up, the resource does not exist — and if it is serving traffic, that window is downtime. The lifecycle block can flip the order.
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
}
}Now, when a replacement is needed, the new one is created and verified first, and the old one is destroyed afterwards. It is not free, though. The two briefly coexist, so resources whose names must be unique (S3 buckets, IAM roles, and so on) fail to create due to a name collision. The companion pattern is the name_prefix argument, which appends a random suffix to the name. You will reach for this especially often with resources that can only be updated through replacement rather than stop-and-start (launch-template-based configurations, for example).
prevent_destroy: a lock on things that must never be deleted #
For resources where deletion itself is the incident — production databases, log buckets — you put a lock on them.
resource "aws_db_instance" "main" {
# ...
lifecycle {
prevent_destroy = true
}
}The moment any plan is produced that would delete this resource (destroy, of course, but also the deletion half of a replacement), Terraform stops with an error. It is a safety net that catches, at the last step, accidents like renaming a resource block during a refactor so that a replacement gets planned (the “forgot state mv” case from #5). One limitation is worth knowing: the protection is only valid inside Terraform. It does not stop someone deleting the resource by hand in the console, and if the block itself is removed from the code, the protection disappears with it. Even so, the cost of adding these two lines to any resource that holds data is near zero and the accidents they prevent are large, so it is well worth making a habit.
ignore_changes: coexisting with changes made outside the code #
In #5 we said drift is a divergence to be reverted, but there is an exception: when another system changing that attribute is normal operation. The classic case is desired_capacity on an Auto Scaling group, where auto scaling adjusts the instance count. If the Terraform code says the initial value is 2 but scaling has raised it to 4, the next apply will revert it to 2 and terminate perfectly healthy servers.
resource "aws_autoscaling_group" "web" {
desired_capacity = 2
# ...
lifecycle {
ignore_changes = [desired_capacity]
}
}Attributes listed in ignore_changes are not treated as differences by plan even when the real value diverges from the code. The code’s value is used only at initial creation, and changes after that are left to the external system. But this is closing Terraform’s eyes to that attribute, so keep the scope minimal — blanket ignoring like ignore_changes = all is effectively abandoning management of that resource.
Forcing a replacement: the -replace flag #
The opposite case also exists: the code is unchanged, but you need a replacement. An instance has gone wrong for reasons unknown and you want to re-provision it fresh. There used to be a terraform taint command that marked a resource as “tainted,” but that approach is deprecated and has been replaced by a flag on plan and apply.
terraform apply -replace="aws_instance.web"This produces a plan that replaces only the specified resource, and the plan output states the reason for the replacement, so the intent is clearer than with taint. It is a tool that fits well with the operational style of swapping servers out instead of repairing them.
Recap #
What we covered in this post:
- Which attributes force a replacement is defined by the provider schema, and you check it via the
# forces replacementannotation in plan output - create_before_destroy flips the replacement order to remove the gap. Resources with unique-name requirements pair it with name_prefix
- prevent_destroy is a lock that turns any deletion plan into an error. Make a habit of putting it on resources that hold data
- ignore_changes excludes from plan the attributes that external systems legitimately change. Keep the scope minimal
- Force a replacement with the
-replaceflag. The taint command is on its way out
In the next post (#8 Module Basics), we cover modules, the tool for organizing code as it grows: how to name and reuse a recurring bundle of resources, and how to pull in proven modules built by others.