How Terraform Actually Figures Out What to Build First
You write a VPC, an ALB, and an ECS Fargate service in the same main.tf. You run terraform apply, and somehow the VPC always gets created first, the ALB second, and Fargate last. You never told Terraform to do that. So how does it know?
The short answer: it doesn’t read your file top to bottom. It builds a graph.
Terraform doesn’t execute files, it executes a graph
On every plan or apply, Terraform does roughly this:
- Parses all your
.tffiles and creates one node per resource. - Looks at every attribute reference inside each resource block.
- Turns those references into edges between nodes.
- Assembles everything into a DAG — a Directed Acyclic Graph.
- Walks the graph, running anything with no unresolved dependencies, in parallel where possible. That’s it. The order of your resource blocks in the file is irrelevant. You could write Fargate first, ALB second, VPC last, and the outcome would be identical.
Where the edges come from
Take this:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_lb" "alb" {
subnets = [aws_subnet.public.id]
# references aws_vpc.main indirectly through the subnet
}
resource "aws_ecs_service" "fargate" {
load_balancer {
target_group_arn = aws_lb_target_group.tg.arn
}
}
When Terraform parses aws_lb.alb, it sees aws_subnet.public.id used inside it. That’s not just interpolation — it’s a signal. Terraform records: this resource can’t be created until that one exists and has an ID. Same story for Fargate referencing the ALB’s target group ARN.
This is called an implicit dependency, and it’s how most ordering happens in practice. You never declare “build order,” you just reference outputs, and Terraform infers the graph from that.
Sometimes there’s no attribute to reference — like waiting on IAM eventual consistency — so you fall back to an explicit dependency:
resource "aws_ecs_service" "fargate" {
depends_on = [aws_iam_role_policy.ecs_exec]
}
The graph itself
Resources become nodes. Dependencies become directed edges. Terraform requires this to be acyclic — if A depends on B and B depends on A, terraform plan fails immediately rather than trying to guess.
For the VPC → ALB → Fargate example, the graph looks like:
aws_vpc.main
└── aws_lb.alb
└── aws_ecs_service.fargate
A graph walker traverses this. Any node whose dependencies are already satisfied can start immediately — including in parallel with unrelated resources. That’s why Terraform can spin up your VPC and, say, an unrelated S3 bucket at the same time, but will always block Fargate until the ALB is done.
Two more things worth knowing:
- The graph is rebuilt every run. Terraform doesn’t remember last time’s order — it recomputes purely from your current configuration.
- Parallelism is bounded, 10 concurrent operations by default, tunable with
-parallelism=n.
See it yourself
You don’t have to take this on faith:
terraform graph | dot -Tsvg > graph.svg
This renders the actual dependency graph Terraform built for your configuration. Genuinely useful the first time you’re debugging a resource that’s applying in an order you didn’t expect — it usually means there’s a reference (or missing one) you didn’t notice.
Why this matters day to day
Once this clicks, a few Terraform behaviors stop being mysterious:
- Why reordering blocks in a file never changes apply order.
- Why removing an attribute reference (and hardcoding a value instead) can silently break your intended ordering.
- Why
depends_onis a last resort, not a habit — it’s for the cases where the graph genuinely can’t see the relationship, not a substitute for wiring outputs to inputs properly. Under the hood, the actual implementation lives in two layers in Terraform’s source: a generic, domain-agnostic DAG library, and a Terraform-specific layer on top that knows about resources, providers, and modules. But you don’t need to read Go source to use this well — you just need to remember that Terraform is reasoning about a graph of references, not a script.