Skip to main content
RunBook Academy

TerraformVIII · Dependencies and the Resource GraphDependencies

Implicit Dependencies Through References

Foundation⏱ ~14 minbash

What you'll learn

  • Explain how Terraform infers implicit dependencies from attribute references
  • Identify which expressions create edges in the resource graph
  • Recognise the silent-misconfig risk of an incidental reference
  • Use locals to separate incidental references from real ordering constraints

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

The default way Terraform orders resources is by reference. When a resource block contains an expression that resolves to an attribute of another resource, a data source, or a module output, Terraform records an edge in the resource graph from the referenced address to the referencing address. The operator does not write a depends_on. Terraform infers the edge from the expression itself.

This lesson is the foundation of the next two: explicit depends_on overrides what the inference produces, and the dependency graph is the data structure that holds both kinds of edges.

A production scenario

A team has a VPC, three subnets, and a security group with ingress rules. The rules reference the VPC id for tagging:

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "eu-west-2a"
}

resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group_rule" "ingress_https" {
  type                     = "ingress"
  from_port                = 443
  to_port                  = 443
  protocol                 = "tcp"
  cidr_blocks              = ["0.0.0.0/0"]
  security_group_id        = aws_security_group.web.id

  tags = {
    VpcId = aws_vpc.main.id
  }
}

The tags = { VpcId = aws_vpc.main.id } line is the interesting one. The team uses the value only as a human-readable label in the AWS console. They do not think of it as a dependency. Terraform does. The rule is forced to wait for the VPC to finish applying before the rule is even attempted. If the VPC creation fails, the rule does not run. The apply graph sees an edge from aws_vpc.main to aws_security_group_rule.ingress_https.

The apply is correct. The apply is also slower than it needs to be. The edge is real even if the operator did not intend it.

How the inference works

Terraform parses the configuration and walks every expression. The walker records an edge whenever it sees:

  • A resource address: aws_vpc.main
  • A nested attribute: aws_vpc.main.id, aws_subnet.a.cidr_block
  • A data source: data.aws_ami.ubuntu.id
  • A module output: module.network.vpc_id
  • A for_each or count reference: aws_subnet.a[each.key].id
  • A splat expression: aws_subnet.a[*].id
  • A try() or lookup() over a referenced map
  • A templatestring() interpolation: "${aws_vpc.main.id}-suffix"

The walker does not record an edge for:

  • A variable: var.region
  • A local value: local.name
  • A literal: "10.0.0.0/16"
  • A provider config: aws.alias.eu.id
  • A path.module / path.root reference
  • A terraform / terraform_remote_state reference

Variables, locals, and literals are evaluated before the graph is built. They have no graph edge because their value is known to Terraform at parse time.

Configuration (HCL)
       |
       v
  +--------------------+
  |  Expression walker |
  +--------------------+
       |
       v
  +--------------------+
  |  Resource graph    |  (DAG: nodes are addresses, edges are dependencies)
  +--------------------+
       |
       v
  Topological sort + parallelism slicing
       |
       v
  Apply order

An example graph

For the configuration above, terraform graph produces (abbreviated):

digraph {
  compound = "true"
  "aws_vpc.main" [label = "aws_vpc.main"]
  "aws_subnet.a" [label = "aws_subnet.a"]
  "aws_security_group.web" [label = "aws_security_group.web"]
  "aws_security_group_rule.ingress_https" [label = "aws_security_group_rule.ingress_https"]

  "aws_subnet.a" -> "aws_vpc.main"
  "aws_security_group.web" -> "aws_vpc.main"
  "aws_security_group_rule.ingress_https" -> "aws_security_group.web"
  "aws_security_group_rule.ingress_https" -> "aws_vpc.main"
}

The last line is the incidental edge. The rule depends on the VPC, but only because of the tag. Removing the tag removes the edge.

The silent-misconfig risk

The risk is not that Terraform is wrong. Terraform records exactly what the expression says. The risk is that the operator writes a reference without realising it is a dependency.

Three common shapes of this risk:

1. Logging or labelling.

tags = {
  SourceBucket = aws_s3_bucket.input.bucket
}

The tag is for human readers. The dependency is real.

2. Conditional expressions over referenced attributes.

count = aws_s3_bucket.input.versioning_enabled ? 1 : 0

The condition reads an attribute of the bucket. The bucket must exist before the count is evaluated. The edge is real.

3. Validation against a referenced attribute.

lifecycle {
  precondition {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR has drifted."
  }
}

The lifecycle precondition reads the VPC. The resource cannot be planned until the VPC has been refreshed. The edge is real.

How to avoid the incidental edge

The fix is to separate “value I want to know” from “value that controls ordering.” Three patterns:

Pattern 1: compute the value in a local.

locals {
  vpc_id_label = aws_vpc.main.id
}

resource "aws_security_group_rule" "ingress_https" {
  # ...
  tags = {
    VpcId = local.vpc_id_label
  }
}

This does not help. The local references the VPC; the resource references the local; the edge is still there. Locals do not break dependency chains; they only rename them.

Pattern 2: read the value at apply time, not at plan time.

Use a data source to read the VPC at apply time. The data source refreshes during the apply and the resource can plan in parallel with the VPC creation:

data "aws_vpc" "by_id" {
  id = "vpc-0123456789abcdef0"
}

resource "aws_security_group_rule" "ingress_https" {
  # ...
  tags = {
    VpcId = data.aws_vpc.by_id.id
  }
}

The data source refreshes from the AWS API. The rule no longer needs the VPC to be in state.

Pattern 3: write the value to state and reference the state attribute of the data source instead.

This is the cleanest separation when the value is needed for display only and the dependency on the underlying resource is incidental.

The choice between patterns depends on whether the reference is to a managed resource (use a data source) or to an attribute the operator controls through Terraform (use the reference and accept the edge).

Failure modes

  1. Tag reference to a slow resource. A tags block referencing a resource that takes minutes to create (RDS, large EC2 instance) serialises everything that touches the tag. The apply blocks on a value used for display only.
  2. Reference inside for_each over a sibling. for_each = toset(aws_s3_bucket.input.tags["Names"]) reads the tags of another bucket. The edge forces a serial apply even if the buckets are independent in every other way.
  3. Reference through a module output. A module output that exposes a value from a deeply nested resource drags the whole nested chain into the consumer’s graph. Splitting the module is the fix.
  4. Reference that becomes null at plan time. A resource reads aws_instance.web.public_dns which is (known after apply). The plan uses a placeholder. If the reference is in an argument that does not tolerate unknowns (e.g. a cidr_block), the apply fails.
  5. Reference inside lifecycle.precondition against an attribute that is (known after apply). The precondition cannot be evaluated; the plan fails with Condition cannot be evaluated at plan time.
  6. Reference to a resource in another state file. Terraform does not cross state boundaries. The reference is resolved at apply time via a terraform_remote_state data source, not as an implicit edge.

How to validate

terraform validate
terraform plan -out=tfplan
terraform graph -type=plan | grep "ingress_https"

The grep filters the graph to the resources you care about. The output shows every edge for that resource. Read the edges. For each edge, decide whether the dependency is intentional. Remove the edges that are not.

Performance implications

Each edge is a serialisation point. A configuration with N independent resources and E edges runs in roughly ceil(N / -parallelism) + E “waves.” A configuration with no edges runs in ceil(N / -parallelism) waves. The difference can be the difference between a 30-second and a 30-minute apply.

For a 100-resource configuration, even 10 incidental edges add 10 serial waits. At 3 seconds per resource, that is 30 seconds of unnecessary latency on every apply.

What to do in production

  • Run terraform graph on every non-trivial change and read the output before merging.
  • Treat every edge as a question: “is this dependency real?”
  • For display-only references, prefer data sources over managed resource references.
  • If a managed resource reference is required and the edge is incidental, accept the cost or restructure the configuration so the reference does not exist.
  • Document the intentional edges in the module’s README. Reviewers should be able to read the documentation and predict the graph.

Security implications

A reference does not grant or revoke permissions. The graph records ordering, not access. The risk is operational: an edge that an operator does not understand is an edge that can be removed by an unrelated change. A removed edge can cause an apply to fail (because the consumer ran before the producer) or to succeed with a stale value (because the consumer used a cached attribute). Both are bad in production. Inspect the graph before merging.

Verification

  • Run terraform validate and confirm the configuration is valid.
  • Run terraform plan -out=tfplan and confirm the plan produces the expected set of changes.
  • Run terraform graph -type=plan and identify every edge for the resources you touched.
  • For each edge, confirm the dependency is intentional. Remove the edges that are not.
  • Re-run terraform plan after removing the incidental edges and confirm the apply order is shorter.

Knowledge check · 7 questions

  1. Q1. What creates an implicit dependency in Terraform?

  2. Q2. Terraform can plan a resource that references an attribute of another resource without knowing the referenced value until apply time.

  3. Q3. A `tags` block references `aws_instance.web.id` only to record the value as a label. What is the operational risk?

  4. Q4. Which of the following expressions create an implicit dependency? (Select all that apply.)

  5. Q5. A `aws_security_group_rule` references `aws_vpc.main.id` in its `tags` block. The VPC is deleted outside Terraform. What happens on the next plan?

  6. Q6. How does Terraform detect implicit dependencies when parsing the configuration?

  7. Q7. A reference inside a `lifecycle.precondition` block creates an implicit dependency.

Passing score: 75%. Answers are checked in this browser.