Learn Terraform - Resource Dependencies & Lifecycle Rules
Episode 7 of 21

Learn Terraform - Resource Dependencies & Lifecycle Rules

In this episode we'll understand how Terraform builds a dependency graph to determine resource creation order, when we need to force order with depends_on, and protect infrastructure with lifecycle rules like create_before_destroy and prevent_destroy.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After discussing Advanced Expressions, Functions & Loops in episode 6 — from built-in functions, the ternary operator, to the count and for_each looping meta-arguments — in this episode we'll discuss a question that arises as soon as our infrastructure starts getting complex: how does Terraform know which resource to create first?

When your stack only contains one S3 bucket, order doesn't matter. But in the real world, a stack contains dozens to hundreds of interrelated resources: a VPC must exist before subnets, subnets must exist before instances, security groups must be referenced by a load balancer, and so on. If the order is wrong, everything errors out.

The good news is that Terraform solves this problem automatically through the dependency graph. But there are times when Terraform can't infer the order correctly, and there are times when we want to protect vital resources from being destroyed by accidents. These are the two themes we'll dissect thoroughly: dependency management and lifecycle rules.

Main Discussion

Resource Dependency Management

Terraform builds a Directed Acyclic Graph (DAG) from all the resources declared in our code. Each resource is a node, and each reference between resources is an edge (arrow) indicating a dependency. Nodes without dependencies can be executed in parallel, while dependent nodes must wait for their predecessors to finish.

Think of it like building a house: walls can't stand before the foundation is done, and the roof can only be installed after the walls are up. A good contractor builds everything in parallel when it's safe, and waits at the points that must be sequential. Terraform does the same thing against the cloud — and this graph is rebuilt every time terraform plan runs.

Implicit Dependencies

First piece of good news: most dependencies don't need to be written by us. When a resource references another resource's attribute, Terraform automatically installs an edge in the dependency graph. This is called an implicit dependency. Notice the VPC stack example below:

implicit-dependency.tf
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
 
  tags = { Name = "main-vpc" }
}
 
resource "aws_subnet" "app" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}
 
resource "aws_security_group" "web" {
  vpc_id = aws_vpc.main.id
}
 
resource "aws_instance" "web" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.app.id
  vpc_security_group_ids = [aws_security_group.web.id]
}

Without a single depends_on line, Terraform knows that aws_subnet.app depends on aws_vpc.main (because of vpc_id = aws_vpc.main.id), and aws_instance.web depends on the subnet and the security group. This order even applies in reverse during destroy: instances are destroyed first, then subnets, then the VPC — a consistent reverse order principle.

Note

References that form implicit dependencies aren't limited to id. References like aws_instance.web.arn, aws_security_group.web.vpc_id, or references from a resource to a data source also build edges. The rule is simple: as long as another resource's name appears inside a resource's attribute block, Terraform records the dependency.

Explicit Dependencies with depends_on

Sometimes dependencies can't be detected automatically. The classic case is a side effect that isn't exposed through an attribute — for example, a resource only works correctly if another resource has finished provisioning, with no attribute reference between them.

A real example that often trips up developers: attaching an IAM policy to a role before the Lambda function starts being invoked. Technically, aws_lambda_function doesn't reference any attribute of the policy attachment, even though Lambda execution needs the already-attached permission.

depends-on-example.tf
resource "aws_iam_role" "lambda_role" {
  name               = "lambda-exec-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
 
resource "aws_iam_role_policy_attachment" "lambda_exec" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
 
resource "aws_lambda_function" "app" {
  function_name = "app-handler"
  role          = aws_iam_role.lambda_role.arn
 
  # Terraform cannot see this relationship implicitly
  depends_on = [aws_iam_role_policy_attachment.lambda_exec]
}

With depends_on, we force Terraform to finish aws_iam_role_policy_attachment.lambda_exec before creating the Lambda. Without that line, the app might work fine in local testing, then intermittently fail in production — very hard to diagnose because it looks random.

Important

A rule of thumb: leverage implicit dependencies as much as possible, and reserve depends_on for relationships truly invisible to the graph. depends_on slows down planning (Terraform must refresh all resources listed in it) and over-locks the ordering, eliminating chances for parallel execution. Too many depends_ons = a fragile and slow graph.

Resource Lifecycle Rules (lifecycle)

Once the order is safe, we move on to protection. The lifecycle block is a kind of seat belt for resources. It doesn't change how a resource is created, but rather changes Terraform's behavior toward that resource: when it's recreated, when it can be destroyed, and what changes should be ignored.

create_before_destroy = true — Rollout Without Downtime

Terraform's default when changing a resource that can't be updated in-place (for example replacing an AMI or changing an attribute that forces recreation) is destroy first, then create (destroy-before-create). The consequence: there's a gap where the resource doesn't exist — i.e., downtime.

For services behind a load balancer or with an alternative point, we can reverse the order with create_before_destroy = true: Terraform creates the new resource first, makes sure it succeeds, then destroys the old one.

create-before-destroy.tf
variable "ami_id" {
  type    = string
  default = "ami-0abcdef1234567890"
}
 
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.app.id
 
  lifecycle {
    create_before_destroy = true
  }
}

When you change var.ami_id and run terraform apply, Terraform will print a different order than usual:

plaintext
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 12s [id=i-0new123]
aws_instance.web (new): Starting...
aws_instance.web: Destroying... [id=i-0old456]
aws_instance.web: Destruction complete after 5s

Notice the order: create → start → destroy. It's precisely this order that makes the zero-downtime strategy work for instances behind a target group — traffic automatically shifts to the new instance when the old one is destroyed.

Caution

create_before_destroy doesn't magically work on all resources. Resources with globally unique name constraints (for example S3 buckets, IAM roles) can't have two instances with the same name at the same time — so changing an attribute attached to the name still results in an error or force replacement. create_before_destroy fits best for resources whose identity is separate from their name (instance IDs, volumes, etc.).

prevent_destroy = true — Protecting Vital Resources

Ever deleted a stack with a misdirected terraform destroy — and the production database disappeared along with it? prevent_destroy = true is the last line of defense for resources that are stateful and irreplaceable: databases, storage buckets containing data, and the like.

prevent-destroy.tf
resource "aws_db_instance" "primary" {
  allocated_storage       = 100
  engine                  = "postgres"
  engine_version          = "16"
  instance_class          = "db.m5.large"
  db_name                 = "app"
  username                = "app"
  skip_final_snapshot     = false
  backup_retention_period = 30
 
  lifecycle {
    prevent_destroy = true
  }
}

Try running terraform destroy — Terraform will halt execution with the following error:

Error: Instance cannot be destroyed
Error: Instance cannot be destroyed
 
  on main.tf line 5, in resource "aws_db_instance" "primary":
   5: resource "aws_db_instance" "primary" {
 
Resource aws_db_instance.primary has lifecycle.prevent_destroy set, but the
plan calls for this resource to be destroyed. To avoid this error and continue
with the plan, either disable lifecycle.prevent_destroy or adjust the scope of
the plan using the -target option.

The error message above is a feature, not a bug — it forces a human to stop and think before obliterating data. To actually delete this resource later, the process is deliberately made inconvenient: remove the lifecycle block or change its flag value to false, then apply.

Warning

prevent_destroy only protects a resource as long as it still exists in the code. If you remove the resource "aws_db_instance" "primary" block from the configuration file, Terraform no longer knows about that resource, and apply will actually destroy it without error — because that resource is no longer declared! The combination of prevent_destroy + terraform state rm is the standard way to "detach yourself" from a vital resource without destroying it (discussed deeper in episode 8).

ignore_changes — Ignoring Changes Outside Terraform

Real-world infrastructure is rarely static. Another team adds tags via the console, an Auto Scaling Group replaces an AMI, or tooling restarts an instance. These changes are called drift. When terraform plan runs, changes outside Terraform will always be detected — and can force Terraform to "normalize" back values that another party actually changed on purpose.

ignore_changes tells Terraform: "don't compare this attribute with state; leave it as is."

ignore-changes.tf
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
 
  tags = {
    Name    = "web-server"
    Managed = "terraform"
  }
 
  lifecycle {
    ignore_changes = [ami, tags]
  }
}
  • ami is ignored: if a CI/CD pipeline updates the AMI outside Terraform, plan won't force a revert.
  • tags is ignored: another team may add tags without Terraform removing them on the next plan.

Another very common example: an EC2 instance joined to an Auto Scaling Group, where the ASG is responsible for managing capacity — not Terraform.

ignore-changes.tf
resource "aws_autoscaling_group" "workers" {
  # ASG manages its own instances
  lifecycle {
    ignore_changes = all
  }
}

Important

To ignore all attributes, use the all keyword: ignore_changes = all. Use it very carefully — the effect is that the resource becomes "semi-out-of-control" from Terraform's perspective. A healthy rule: ignore_changes is only for attributes genuinely managed by another party, not for covering up drift that should be handled.

lifecycle Argument Reference Table

ArgumentDefaultFunction
create_before_destroyfalseCreates the new resource before destroying the old one (zero-downtime)
prevent_destroyfalsePrevents resource deletion (error when the plan calls for destroy)
ignore_changes[]Ignores certain attribute changes (or all) against state
replace_triggered_bynullForces resource replacement when another resource's value changes (Terraform 1.2+)
precondition / postconditionnullValidates conditions before/after a resource is applied (Terraform 1.2+)

replace_triggered_by and precondition/postcondition are newer features worth learning once you're comfortable with the three main arguments above.

Real Case Study: Zero-Downtime + Stateful Protection

Combine it all in one real scenario. Say a production web service stack:

  • Web instances behind a target group → create_before_destroy = true for safe rollout.
  • RDS databaseprevent_destroy = true so no human can destroy it with a single terraform destroy.
  • Auto Scaling Groupignore_changes = all because capacity is managed by the ASG, not Terraform.
production-stack.tf
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.medium"
  subnet_id     = aws_subnet.app.id
 
  lifecycle {
    create_before_destroy = true
  }
}
 
resource "aws_db_instance" "primary" {
  engine         = "postgres"
  instance_class = "db.m5.large"
  db_name        = "app"
  username       = "app"
 
  lifecycle {
    prevent_destroy = true
  }
}
 
resource "aws_autoscaling_group" "workers" {
  lifecycle {
    ignore_changes = [desired_capacity, tags]
  }
}

These three rules are the default patterns used by production teams at many large companies: safe rolling updates, protected irreplaceable data, and explicitly managed drift noise.

Common Pitfalls

1. Creating a circular dependency with depends_on

Terraform blocks circular dependencies (A waits for B, B waits for A). The error that appears is Cycle: aws_a.web, aws_b.web and is hard to track down once the config is large. A quick detection rule: if two resources reference each other's attributes, there's almost certainly a design error — separate the data or use a data source.

2. create_before_destroy on uniquely-named resources

Renaming an S3 bucket with create_before_destroy = true will fail because the new bucket must be created before the old one is destroyed, even though the name is the same. In this case, Terraform will still do a replace but can potentially error out at the cloud — always verify resource name constraints in the provider documentation.

3. prevent_destroy only protects resources that are still declared

As explained in the previous callout: removing the resource block from code = destruction on the next apply, even if prevent_destroy was set. To tear down a resource permanently, do it deliberately and in layers: remove prevent_destroy, apply, then destroy.

4. Excessive ignore_changes = all

Ignoring all changes makes Terraform blind to real problems — for example an instance swapped to a different type in the console without the team's knowledge, or a security group changed to open a dangerous port. Limit ignore_changes only to attributes genuinely outside Terraform's control.

5. Unnecessary depends_on = slow plan

depends_on forces Terraform to refresh all referenced resources even when they haven't changed. In large stacks with thousands of resources, this adds seconds or even minutes to every plan. Prune unneeded depends_ons periodically.

Conclusion

In this episode 7 we've understood the two pillars of infrastructure control: dependency management — how Terraform arranges order automatically through implicit dependencies, and when we force order with depends_on — as well as lifecycle rules that act as resource seat belts: create_before_destroy for downtime-free rollouts, prevent_destroy to protect stateful resources, and ignore_changes to manage drift.

This capability is what differentiates "running" configurations from configurations that are "safe in the hands of many people". Correct ordering prevents random errors, while lifecycle rules prevent disasters that can't be postponed. In production teams, the combination of the three is a mandatory standard before a stack is allowed to touch a production environment.

But there's one big unanswered question: what if our infrastructure already exists in the cloud, or resources in code must be moved to a new place? In the next episode 8 we'll discuss Advanced State Manipulationimport to connect existing infrastructure, the moved block and terraform state mv for destroy-free refactoring, as well as terraform state rm, state list, and state show for state inspection. Stay excited!