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.

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.
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.
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:
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.
depends_onSometimes 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.
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.
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 DowntimeTerraform'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.
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:
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 5sNotice 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 ResourcesEver 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.
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
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 TerraformReal-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."
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.
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| Argument | Default | Function |
|---|---|---|
create_before_destroy | false | Creates the new resource before destroying the old one (zero-downtime) |
prevent_destroy | false | Prevents resource deletion (error when the plan calls for destroy) |
ignore_changes | [] | Ignores certain attribute changes (or all) against state |
replace_triggered_by | null | Forces resource replacement when another resource's value changes (Terraform 1.2+) |
precondition / postcondition | null | Validates 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.
Combine it all in one real scenario. Say a production web service stack:
create_before_destroy = true for safe rollout.prevent_destroy = true so no human can destroy it with a single terraform destroy.ignore_changes = all because capacity is managed by the ASG, not Terraform.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.
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.
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 Manipulation — import 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!