References, Dependencies & the Resource Graph
Terraform Knows the Order — That’s the Point
One of Terraform’s most powerful properties is that you don’t write step-by-step provisioning scripts. You don’t say “create the VPC, then create the subnet, then create the instance.” You declare all three, and Terraform figures out the order.
It does this by building a dependency graph — a directed acyclic graph (DAG) that maps which resources depend on which other resources. Understanding this graph is understanding how Terraform decides what to do and in what sequence.
This is fundamentally different from imperative tools where you specify the order. In Terraform, the order is derived from the data flow. If subnet uses aws_vpc.main.id, Terraform knows the VPC must exist before the subnet. You didn’t tell it — it inferred it from the reference.
Implicit Dependencies: References Do the Heavy Lifting
The most common way to express a dependency is to reference one resource’s attribute in another:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # ← reference to aws_vpc.main
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id # ← reference to aws_subnet.public
}
Terraform reads this and constructs the graph:
aws_vpc.main
↓
aws_subnet.public
↓
aws_instance.web
Creation order: VPC → Subnet → Instance
Destruction order: Instance → Subnet → VPC (reversed)
You wrote no explicit ordering. Terraform inferred it entirely from attribute references. This is the system working as designed — the data flow defines the execution order.
Why is destruction reversed? Because you can’t delete a VPC that still contains subnets, and you can’t delete a subnet that still contains EC2 instances. The cloud APIs enforce these constraints, and Terraform’s dependency graph respects them automatically.
How to Reference Different Named Values
Every value in Terraform has a reference path. The format depends on what you’re referencing:
# Resource attributes
aws_instance.web.id
aws_instance.web.public_ip
aws_s3_bucket.data.arn
# Variables
var.environment
var.instance_type
# Local values
local.name_prefix
local.common_tags
# Module outputs
module.networking.vpc_id
module.compute.instance_ids
# Data source attributes
data.aws_ami.ubuntu.id
data.aws_vpc.existing.cidr_block
# Special references
self.public_ip # Inside provisioner blocks
each.key / each.value # Inside for_each iterations
count.index # Inside count iterations
path.module # Directory of the current module
path.root # Directory of the root module
Every reference creates a dependency edge in the graph. When Terraform sees subnet_id = aws_subnet.public.id, it adds an edge: this resource depends on that subnet. The graph grows organically from how you wire things together.
Explicit Dependencies: depends_on — The Exception, Not the Rule
Sometimes there’s a dependency that can’t be expressed through attribute references. The classic example: an EC2 instance needs an IAM policy to be attached before it launches, but the instance config doesn’t reference the policy attachment directly.
resource "aws_iam_role_policy_attachment" "s3_access" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}
resource "aws_instance" "app" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name
depends_on = [aws_iam_role_policy_attachment.s3_access]
# ^^^ Explicit: don't launch the instance until the policy is fully attached
}
Without depends_on, Terraform might create the instance before the policy attachment is complete. The instance would boot, try to access S3, and fail — even though the policy attachment is being created in parallel. With depends_on, the ordering is guaranteed.
Why depends_on should be rare: it’s a manual override of Terraform’s automatic dependency inference. If you find yourself adding depends_on everywhere, it usually means your resource references aren’t properly expressing the relationships. The cleaner solution is almost always a proper attribute reference that creates an implicit dependency.
Over-reliance on depends_on is a design smell. It means the dependency graph is partially hand-maintained instead of automatically derived, which defeats one of Terraform’s core advantages.
Parallel Execution — Why the Graph Matters for Performance
Resources with no dependency relationship between them are created in parallel. Terraform maximizes parallelism within the constraints of the graph.
resource "aws_s3_bucket" "logs" { ... }
resource "aws_s3_bucket" "backups" { ... }
resource "aws_s3_bucket" "assets" { ... }
resource "aws_sqs_queue" "events" { ... }
No references between them. Terraform creates all four simultaneously. For large configs with 50+ independent resources, this parallelism cuts provisioning time dramatically — minutes instead of hours.
The default parallelism is 10 concurrent operations. You can tune it:
terraform apply -parallelism=20 # More parallelism, faster but harder on API rate limits
terraform apply -parallelism=5 # Less parallelism, gentler on API rate limits
Higher parallelism means faster deploys but more concurrent API calls. If you’re hitting rate limits from your cloud provider, reducing parallelism can help. If your deploys are slow and your account has generous rate limits, increasing it speeds things up.
You can visualize the graph:
terraform graph | dot -Tsvg > graph.svg
open graph.svg
This produces a visual DAG showing all resources and their dependencies. Useful for debugging ordering issues or understanding why a specific resource takes so long to create (it might be waiting on a long chain of dependencies).
The -target Flag: Surgical Operations
terraform plan -target=aws_instance.web
terraform apply -target=aws_instance.web
This limits the plan/apply to a specific resource (and anything it depends on). Everything else is ignored.
When it’s useful:
– During development, testing a single resource without evaluating the entire config
– During an incident, fixing one broken resource without touching the rest
– Destroying a specific resource: terraform destroy -target=aws_instance.web
Why it’s dangerous:
– It creates state inconsistency — other resources depending on the targeted one may be out of sync
– It can mask problems — if you only apply one resource at a time, you never see the full picture
– It’s addictive — teams start using it as a workflow instead of the exception
HashiCorp explicitly states that -target is a troubleshooting escape hatch, not a standard practice. If you find yourself using it regularly, your Terraform config is probably too large and should be split into smaller, independent workspaces.
Resource Addressing — The Full Reference System
Every resource has a unique address. You’ll encounter these in plan output, state commands, import operations, and -target flags:
| Configuration | Address |
|---|---|
resource "aws_instance" "web" | aws_instance.web |
With count = 3 | aws_instance.web[0], [1], [2] |
With for_each (map keys) | aws_instance.web["us-east-1a"] |
| Inside a module | module.networking.aws_vpc.main |
| Nested modules | module.app.module.networking.aws_vpc.main |
These addresses are how you interact with specific resources via CLI:
terraform state show aws_instance.web
terraform state mv aws_instance.web aws_instance.app
terraform import aws_instance.web i-0abc123def
terraform apply -target=module.networking
Design Scenario: Three-Tier Application Graph
# Network layer
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }
resource "aws_subnet" "public" { vpc_id = aws_vpc.main.id; ... }
resource "aws_subnet" "private" { vpc_id = aws_vpc.main.id; ... }
# Security layer (depends on VPC, but not on subnets)
resource "aws_security_group" "web" { vpc_id = aws_vpc.main.id; ... }
resource "aws_security_group" "app" { vpc_id = aws_vpc.main.id; ... }
# Compute layer (depends on subnets and security groups)
resource "aws_instance" "web" {
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
}
resource "aws_db_instance" "main" {
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.app.id]
}
Terraform resolves the graph: create the VPC first. Then subnets and security groups can be created in parallel (they both depend on the VPC but not on each other). Then compute resources (they depend on both subnets and security groups). Total wall-clock time is minimized because everything that can safely run in parallel does.
Key Takeaways
References create dependencies automatically. If resource A references an attribute of resource B, Terraform knows B must exist first. This implicit system handles 90% of ordering needs without any manual intervention.
depends_on exists for the remaining 10% — cases where there’s a real ordering requirement but no attribute reference to express it. Use it sparingly. If you’re adding it to every other resource, your config design probably needs rethinking.
The dependency graph drives both ordering and parallelism. Resources without dependencies between them are created simultaneously. This is why a 50-resource config deploys in minutes, not sequentially over an hour. The graph isn’t just an ordering tool — it’s a performance optimization.

