Skip to main content

Terraform Resource Lifecycle — Create, Update, Destroy, and Taint

· 6 min read
Goel Academy
DevOps & Cloud Learning Hub

You rename a tag in your Terraform config, expecting a simple metadata update. Instead, Terraform announces it will destroy and recreate your production database. Your heart rate spikes. Understanding how Terraform decides to create, update, or destroy resources — and how to control that behavior — is the difference between a calm deploy and a production incident.

Resource Lifecycle Phases

Every Terraform resource goes through a lifecycle managed by the provider. When you run terraform apply, each resource can undergo one of these operations:

SymbolOperationWhat Happens
+CreateResource does not exist, Terraform will create it
~Update in-placeChange a mutable attribute (e.g., tags, instance type with stop/start)
-DestroyResource exists in state but not in config, Terraform will delete it
-/+Destroy and recreateAn immutable attribute changed (e.g., AMI ID), so Terraform must replace it
+/-Create then destroySame as above, but with create_before_destroy enabled
<=Read (data source)Terraform will read data from the provider

Understanding these symbols in terraform plan output is essential. Let us look at what drives each decision.

When Does Terraform Destroy and Recreate?

Some resource attributes are immutable — they cannot be changed after creation. When you modify one, Terraform has no choice but to destroy the old resource and create a new one.

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0" # Changing this forces replacement
instance_type = "t3.micro" # Changing this = in-place update (stop/start)

tags = {
Name = "web-server" # Changing this = in-place update
}
}

The plan output tells you exactly what will happen:

# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
~ ami = "ami-0c55b159cbfafe1f0" -> "ami-0abcdef1234567890" # forces replacement
~ id = "i-0abc123def456789" -> (known after apply)
instance_type = "t3.micro"
tags = {
"Name" = "web-server"
}
}

The # forces replacement comment is your signal. Any attribute marked this way in the provider documentation will trigger a destroy/recreate cycle.

Lifecycle Meta-Arguments

Terraform provides four lifecycle meta-arguments to control how resources are managed.

create_before_destroy

By default, Terraform destroys the old resource first, then creates the new one. This causes downtime. With create_before_destroy, Terraform spins up the replacement first, then destroys the old one.

resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type

lifecycle {
create_before_destroy = true
}

tags = {
Name = "web-${var.environment}"
}
}

This is critical for resources behind a load balancer. The new instance comes up and starts serving traffic before the old one is terminated.

prevent_destroy

This is your safety net against accidental destruction of critical resources like databases and S3 buckets containing important data.

resource "aws_db_instance" "production" {
identifier = "prod-database"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.r6g.large"
username = "admin"
password = var.db_password

lifecycle {
prevent_destroy = true
}
}

If any operation would destroy this resource — including terraform destroy — Terraform exits with an error:

Error: Instance cannot be destroyed

on main.tf line 1:
1: resource "aws_db_instance" "production" {

Resource aws_db_instance.production has lifecycle.prevent_destroy set,
but the plan calls for this resource to be destroyed.

You must remove the prevent_destroy setting first, then run apply or destroy. This intentional friction prevents accidents.

ignore_changes

Sometimes external systems modify resource attributes outside of Terraform — auto-scaling groups adjust instance counts, deployment tools update container images, or users add tags manually. You can tell Terraform to ignore specific attributes:

resource "aws_autoscaling_group" "app" {
name = "app-asg"
min_size = 2
max_size = 10
desired_capacity = 2
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
vpc_zone_identifier = var.private_subnet_ids

lifecycle {
ignore_changes = [
desired_capacity, # Auto-scaling changes this; do not revert it
]
}
}

Without ignore_changes, every terraform apply would reset desired_capacity back to 2, undoing the auto-scaler's work.

You can also ignore all changes with a wildcard:

lifecycle {
ignore_changes = all
}

Use this sparingly. It effectively makes the resource "create-only" — Terraform creates it but never updates it.

replace_triggered_by

Introduced in Terraform 1.2, this forces a resource to be replaced when a referenced resource or attribute changes.

resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
user_data = file("scripts/setup.sh")

lifecycle {
replace_triggered_by = [
null_resource.force_replace # Replace the instance when this changes
]
}
}

resource "null_resource" "force_replace" {
triggers = {
setup_script_hash = filemd5("scripts/setup.sh")
}
}

When the setup script changes, the null_resource triggers replacement, and that cascading trigger replaces the EC2 instance too.

terraform taint vs terraform apply -replace

The terraform taint command was the old way to force-recreate a resource. It marks a resource in state as "tainted," so the next apply destroys and recreates it.

# Deprecated approach
terraform taint aws_instance.web
terraform apply

# Modern approach (Terraform 1.5+)
terraform apply -replace="aws_instance.web"

The -replace flag is better because it combines marking and applying in one step, and it shows you the full plan before making changes. terraform taint is deprecated and will be removed in a future version.

Explicit Dependencies with depends_on

Terraform automatically detects dependencies when one resource references another's attributes. But sometimes dependencies are implicit — a security group rule must exist before an instance starts, even if there is no direct reference.

resource "aws_iam_role_policy" "app_policy" {
name = "app-s3-access"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "${aws_s3_bucket.app_data.arn}/*"
}]
})
}

resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name

# The instance needs the policy to exist before it boots,
# but there is no direct attribute reference to the policy
depends_on = [aws_iam_role_policy.app_policy]
}

Use depends_on only when automatic dependency detection is insufficient. Overusing it can slow down applies by preventing parallelism.

Visualizing Dependencies with terraform graph

Terraform can generate a dependency graph in DOT format:

# Generate a visual dependency graph
terraform graph | dot -Tpng > graph.png

# Or just output the DOT format to review
terraform graph

The output is a directed acyclic graph (DAG) showing which resources depend on which. Terraform uses this graph to determine the order of operations and what can run in parallel.

Reading the Plan Output

Here is a realistic plan output and how to read it:

Terraform will perform the following actions:

# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0abc123def456789"
~ instance_type = "t3.micro" -> "t3.small"
tags = {
"Name" = "web-server"
}
}

# aws_security_group_rule.allow_https will be created
+ resource "aws_security_group_rule" "allow_https" {
+ cidr_blocks = ["0.0.0.0/0"]
+ from_port = 443
+ protocol = "tcp"
+ security_group_id = "sg-0abc123def456789"
+ to_port = 443
+ type = "ingress"
}

# aws_instance.legacy will be destroyed
- resource "aws_instance" "legacy" {
- ami = "ami-old123" -> null
- id = "i-0legacy456" -> null
- instance_type = "t2.micro" -> null
}

Plan: 1 to add, 1 to change, 1 to destroy.

Always read the summary line at the bottom. If you see unexpected destroys, stop and investigate before typing "yes."

Wrapping Up

The lifecycle block is one of Terraform's most important features. Get comfortable with create_before_destroy for zero-downtime replacements, prevent_destroy for critical resources, and ignore_changes for attributes managed outside Terraform. These three settings alone will save you from the most common production incidents.

In the next post, we will cover Terraform data sources — how to query and reference existing infrastructure that Terraform did not create.