The Dependency Graph: Implicit, depends_on, and -target

K
Kai··6 min read

Since Article 1 we've heard the line "Terraform performs the operations in the correct order, respecting any resource dependencies." Article 3 said line order in the file decides nothing. Article 4 noted state stores dependency metadata to delete in the right order. Now we dissect the common mechanism behind all of it: the dependency graph. Understand it and what Terraform creates first, deletes last, stops being a mystery.

Goal

See the dependency graph Terraform builds from your configuration, understand where implicit dependencies come from, know when you must declare a dependency manually with depends_on, and why -target should only be used in tight situations.

Implicit dependencies: references are everything

Stand up two related resources: a bucket, and versioning enabled on that bucket.

resource "aws_s3_bucket" "data" {
  bucket_prefix = "tf-series-bai5-"
  force_destroy = true
}

resource "aws_s3_bucket_versioning" "data" {
  bucket = aws_s3_bucket.data.id

  versioning_configuration {
    status = "Enabled"
  }
}

The crux is the line bucket = aws_s3_bucket.data.id. The versioning resource needs to know the bucket name, and it gets it by referencing the id attribute of the bucket resource. That reference creates an implicit dependency: Terraform sees versioning reading a value from the bucket, so it understands the bucket must exist first. You declare the order nowhere — it's inferred from who references whom.

Apply and notice the order:

$ terraform apply -auto-approve
aws_s3_bucket.data: Creating...
aws_s3_bucket.data: Creation complete after 3s [id=tf-series-bai5-20260525025940839300000001]
aws_s3_bucket_versioning.data: Creating...
aws_s3_bucket_versioning.data: Creation complete after 2s [id=...]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

The bucket finishes fully before versioning starts, and although we wrote versioning later in the file, that's not the reason — if you swap the two blocks, the result is the same.

Seeing the graph with your own eyes

The terraform graph command outputs the graph in DOT format:

$ terraform graph
digraph G {
  rankdir = "RL";
  node [shape = rect, fontname = "sans-serif"];
  "aws_s3_bucket.data" [label="aws_s3_bucket.data"];
  "aws_s3_bucket_versioning.data" [label="aws_s3_bucket_versioning.data"];
  "aws_s3_bucket_versioning.data" -> "aws_s3_bucket.data";
}

The important line is the edge "aws_s3_bucket_versioning.data" -> "aws_s3_bucket.data": versioning points to the bucket, meaning "versioning depends on the bucket." This is how Terraform models your entire configuration — a directed graph, each resource a vertex, each reference an edge. (DOT can be rendered to an image with Graphviz: terraform graph | dot -Tpng > graph.png, useful for large infrastructure.)

With the graph in hand, Terraform does a topological sort to derive the order of operations: vertices that depend on no one go first, vertices that depend on others wait for what they need to finish. A consequence few people notice: resources with no edge connecting them are independent, so Terraform creates them in parallel (by default up to 10 operations at once). This is why applying large infrastructure is faster than you'd think — it doesn't go one at a time, only serially along the dependency edges.

   apply: following the edges       destroy: reversing the edges
   ───────────────────────          ─────────────────────────
   1. aws_s3_bucket.data            1. aws_s3_bucket_versioning.data
            │ (finishes first)              │ (removed first)
            ▼                                ▼
   2. aws_s3_bucket_versioning.data  2. aws_s3_bucket.data

Why destroy reverses the order

On the same graph, when deleting Terraform walks the edges in reverse. The reason is natural: if B depends on A, then when creating you must have A first, but when deleting you must remove B first and only then A — you can't delete something another thing relies on. Observe destroy:

$ terraform destroy -auto-approve
aws_s3_bucket_versioning.data: Destroying...
aws_s3_bucket_versioning.data: Destruction complete after 1s
aws_s3_bucket.data: Destroying...
aws_s3_bucket.data: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.

Versioning is removed first, the bucket after, exactly the reverse of creation. This is also why Article 4 said state must remember dependencies: when you remove a resource from the configuration, it's no longer in the file to infer the order from, so that order comes from the metadata in state.

When references aren't enough: depends_on

Implicit dependencies catch most cases, because usually this resource needs a value from that resource. But there are hidden dependencies the configuration doesn't expose through any reference. The docs describe depends_on as for "handling hidden dependencies between resources or modules that Terraform can't infer on its own."

A common example from the docs: an EC2 instance needs an IAM role policy ready when it boots to run software inside, but the instance doesn't reference that policy in any argument. This relationship lives at the application layer; Terraform can't see it. Declare it manually:

resource "aws_instance" "app" {
  # ... no line references the policy ...
  depends_on = [aws_iam_role_policy.app]
}

The docs are clear that depends_on "should be used only as a last resort, since it makes Terraform plan more conservatively, replacing more resources than necessary." The reason: because it doesn't know which value is involved, Terraform has to assume the worst-case scenario. The practical rule: prefer a direct reference (aws_iam_role_policy.app.arn) whenever possible, because it both creates the dependency and tells Terraform exactly which value depends on it; only fall back to depends_on when the relationship genuinely can't be expressed through any value.

-target: an escape hatch, not a daily tool

Sometimes you want to apply only part of the graph, for example while debugging a stubborn resource. The -target flag lets you limit operations to one (or a few) resources:

terraform apply -target=aws_s3_bucket.data

Terraform still respects the targeted resource's dependencies (creating whatever it needs too), but skips the rest of the graph. The problem is that doing so makes state and configuration deviate from each other on purpose, and Terraform itself prints a warning that this is a feature for special cases, not to be used routinely. If you find yourself typing -target repeatedly to apply "for speed," that's a sign you should split the configuration into smaller states (Part V) rather than abuse the flag. Treat it as an escape hatch: there to use when stuck, not for everyday travel.

Wrap-up

Terraform models the configuration as a directed graph: resources are vertices, references between them are edges, forming implicit dependencies. The graph command shows that graph; from it Terraform does a topological sort to derive the order, parallelizes independent branches, and reverses the order when destroying. When a relationship isn't exposed through a reference (a hidden dependency at the application layer), depends_on declares it manually, but only use it when you must. -target limits operations to part of the graph, an escape hatch rather than a habit.

With that, Part I closes: we've grasped IaC and the Terraform architecture, the command lifecycle, HCL, state, and the dependency graph — enough foundation to enter the hands-on part. Part II opens with the most pressing problem once you leave your personal machine: the state file is sitting local, not safe, not shareable. Article 6 moves it to S3 with state locking via use_lockfile, the current approach replacing the outdated DynamoDB.