Terraform Lesson 21 – Dependencies | Dataplexa
Section II · Lesson 21

Dependencies

Terraform builds a dependency graph from your configuration and uses it to determine creation order, parallelism, and destruction order. Most of the time this is invisible — it just works. But when you need to debug a timing issue, fix an unexpected destroy order, or understand why a configuration fails only on the first apply, the dependency graph is where the answer lives.

This lesson covers

Implicit vs explicit dependencies → How Terraform builds the graph → Parallelism and why it matters → Dependency cycles and how to fix them → terraform graph for visualisation → Real patterns that create hidden dependencies

How Terraform Builds the Dependency Graph

When you run terraform plan, Terraform Core reads every resource block and builds a directed acyclic graph — a DAG — by scanning all attribute references. Every time resource A references an attribute of resource B, an edge is drawn from A to B. This edge means: B must exist before A can be created.

Once the graph is built, Terraform walks it from root to leaf — creating resources with no dependencies first, then working outward. Resources with no dependency between each other are created in parallel. Destruction reverses the graph.

The Analogy

The dependency graph is like a recipe with steps. You cannot frost a cake before it is baked. You cannot bake a cake before the batter is mixed. You cannot mix batter before you have eggs and flour. But while you are waiting for the cake to bake, you can prepare the frosting in parallel — it has no dependency on the baking step. Terraform reads your configuration the same way a chef reads a recipe — finding what must happen in order and what can happen at the same time.

Setting Up

Create a project that builds a realistic multi-resource AWS environment — VPC, subnets, security group, IAM role, EC2 instance. This gives us enough resources to demonstrate dependency patterns, parallelism, and the terraform graph command meaningfully.

mkdir terraform-lesson-21
cd terraform-lesson-21
touch versions.tf main.tf outputs.tf .gitignore

Add this to versions.tf:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

Run terraform init then build the full configuration below.

Implicit Dependencies — The Default

An implicit dependency is created automatically whenever a resource references an attribute of another resource. You write the reference expression — Terraform builds the edge. This is how the vast majority of dependencies work in real configurations.

New terms:

  • implicit dependency — created when resource A's configuration contains an expression that references an attribute of resource B. Terraform detects the reference and automatically creates a dependency edge: B is created before A. No explicit declaration is needed.
  • resource address in expressions — the syntax TYPE.NAME.ATTRIBUTE. For example: aws_vpc.main.id. When this appears in a resource block, Terraform parses it and registers a dependency on aws_vpc.main.
  • aws_iam_instance_profile — an IAM construct that allows an EC2 instance to assume an IAM role. The instance profile wraps the role and is what gets attached to an EC2 instance — not the role itself. The chain is: IAM Role → IAM Instance Profile → EC2 Instance.

Add this to main.tf:

# ── NETWORKING LAYER ─────────────────────────────────────────────────────────

# VPC — no dependencies, created first
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = { Name = "lesson21-vpc", ManagedBy = "Terraform" }
}

# Internet gateway — depends on VPC via vpc_id reference
# Terraform sees aws_vpc.main.id and creates the edge: VPC -> IGW
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id  # Implicit dependency: aws_vpc.main must exist first

  tags = { Name = "lesson21-igw", ManagedBy = "Terraform" }
}

# Two subnets — each depends on VPC, but NOT on each other
# Terraform will create them in parallel after the VPC is ready
resource "aws_subnet" "public_a" {
  vpc_id            = aws_vpc.main.id  # Implicit dependency: aws_vpc.main
  cidr_block        = "10.0.1.0/24"
  availability_zone = "us-east-1a"
  map_public_ip_on_launch = true

  tags = { Name = "lesson21-subnet-a", ManagedBy = "Terraform" }
}

resource "aws_subnet" "public_b" {
  vpc_id            = aws_vpc.main.id  # Same dependency as public_a — but independent of it
  cidr_block        = "10.0.2.0/24"
  availability_zone = "us-east-1b"
  map_public_ip_on_launch = true

  tags = { Name = "lesson21-subnet-b", ManagedBy = "Terraform" }
}

# Security group — depends on VPC
resource "aws_security_group" "web" {
  name   = "lesson21-web-sg"
  vpc_id = aws_vpc.main.id  # Implicit dependency: aws_vpc.main

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "All outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "lesson21-web-sg", ManagedBy = "Terraform" }
}

# ── IAM LAYER ────────────────────────────────────────────────────────────────

# IAM role — no dependencies on networking, can be created in parallel with VPC
resource "aws_iam_role" "web_instance" {
  name = "lesson21-web-instance-role"

  # Trust policy — allows EC2 to assume this role
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = { Name = "lesson21-web-role", ManagedBy = "Terraform" }
}

# Instance profile — wraps the IAM role so EC2 can use it
# Depends on the role via iam_role reference
resource "aws_iam_instance_profile" "web" {
  name = "lesson21-web-profile"
  role = aws_iam_role.web_instance.name  # Implicit dependency: aws_iam_role.web_instance
}

# ── COMPUTE LAYER ────────────────────────────────────────────────────────────

# EC2 instance — depends on subnet, security group, and instance profile
# Depends on internet gateway via depends_on — explicit dependency needed
resource "aws_instance" "web" {
  ami                    = "ami-0c55b159cbfafe1f0"  # Amazon Linux 2 — us-east-1
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.public_a.id          # Implicit: aws_subnet.public_a
  vpc_security_group_ids = [aws_security_group.web.id]     # Implicit: aws_security_group.web
  iam_instance_profile   = aws_iam_instance_profile.web.name  # Implicit: aws_iam_instance_profile.web

  # Explicit dependency — the instance needs internet access at launch
  # It references no attribute of the internet gateway, so Terraform cannot
  # infer this dependency automatically
  depends_on = [aws_internet_gateway.main]

  tags = { Name = "lesson21-web", ManagedBy = "Terraform" }

  lifecycle {
    create_before_destroy = true
    ignore_changes        = [ami]
  }
}
$ terraform apply

Plan: 7 to add, 0 to change, 0 to destroy.

  Enter a value: yes

# Creation order — Terraform shows what runs in parallel

# Round 1 — no dependencies — all start simultaneously
aws_vpc.main: Creating...
aws_iam_role.web_instance: Creating...

# Round 2 — after VPC is ready, these run in parallel
aws_vpc.main: Creation complete [id=vpc-0abc123]
aws_internet_gateway.main: Creating...
aws_subnet.public_a: Creating...
aws_subnet.public_b: Creating...
aws_security_group.web: Creating...

# IAM role completes independently — feeds into instance profile
aws_iam_role.web_instance: Creation complete [id=lesson21-web-instance-role]
aws_iam_instance_profile.web: Creating...

# Networking completes
aws_internet_gateway.main: Creation complete [id=igw-0abc123]
aws_subnet.public_a: Creation complete [id=subnet-0aaa111]
aws_subnet.public_b: Creation complete [id=subnet-0bbb222]
aws_security_group.web: Creation complete [id=sg-0abc123]
aws_iam_instance_profile.web: Creation complete [id=lesson21-web-profile]

# Round 3 — EC2 instance — depends on all of the above
aws_instance.web: Creating...
aws_instance.web: Creation complete [id=i-0abc123def456789]

Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

What just happened?

  • The VPC and IAM role started simultaneously in Round 1. These two resources have zero dependencies between them. Terraform detected this from the graph and launched both API calls at the same time. This is why a configuration with many independent resources deploys faster than you might expect — Terraform maximises parallelism automatically.
  • The two subnets were created in parallel in Round 2. Both reference aws_vpc.main.id — so both depend on the VPC. But they have no dependency between each other. After the VPC completed, both subnets started simultaneously alongside the internet gateway and security group. Four resources running in parallel from one dependency being satisfied.
  • The EC2 instance waited for everything. It depends on the subnet (for placement), the security group (for networking), the instance profile (for IAM), and the internet gateway via depends_on (for connectivity). It was the last resource to start — exactly as the dependency graph required.

Explicit Dependencies — depends_on

depends_on creates an explicit dependency that Terraform cannot infer from attribute references alone. Use it when a resource depends on another resource's existence or side effects — not just its attribute values.

The most common scenarios where depends_on is genuinely needed:

Internet gateway before EC2

An EC2 instance in a public subnet needs the internet gateway to reach the internet at boot time. The instance references the subnet ID and security group ID — but neither is an attribute of the internet gateway. Without depends_on, the instance might launch before the gateway is attached, and bootstrap scripts that need internet access will fail.

IAM policy before resource that uses it

An S3 bucket policy or an IAM policy attachment may need to exist before the resource that relies on the permissions. The dependent resource references the role name or ARN — not the policy content. depends_on ensures the policy is fully applied before the dependent resource is created.

Route table association before routing-dependent resources

A route table can be created and associated with a subnet, but an EC2 instance in that subnet does not reference the route table. If internet routing must be set up before the instance, depends_on enforces the order.

New terms:

  • depends_on meta-argument — available on any resource or module block. Takes a list of resource references — not attribute references. depends_on = [aws_internet_gateway.main] creates a hard dependency: the internet gateway must be fully created before this resource begins creation. It also affects destruction order — the dependent resource is destroyed first.
  • module depends_on — when placed on a module block, depends_on forces every resource inside the module to wait for the listed dependencies. Use sparingly — it prevents any parallelism between the module's resources and the listed dependencies.
  • overuse of depends_on — every explicit dependency reduces parallelism. If a dependency can be expressed through an attribute reference, do it that way. Only use depends_on when no attribute reference captures the real dependency.
# Route table — depends on VPC and internet gateway
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id  # Implicit dependency: aws_vpc.main

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id  # Implicit dependency: aws_internet_gateway.main
  }

  tags = { Name = "lesson21-rt-public", ManagedBy = "Terraform" }
}

# Route table association — connects the route table to both public subnets
resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id      # Implicit: aws_subnet.public_a
  route_table_id = aws_route_table.public.id   # Implicit: aws_route_table.public
}

resource "aws_route_table_association" "public_b" {
  subnet_id      = aws_subnet.public_b.id      # Implicit: aws_subnet.public_b
  route_table_id = aws_route_table.public.id   # Implicit: aws_route_table.public
}

# Lambda function that needs the VPC security group but does not reference its ID
# It uses depends_on because the security group must exist before the Lambda
# can be attached to the VPC — but Lambda config references group by name, not ID
resource "aws_lambda_function" "processor" {
  filename      = "processor.zip"
  function_name = "lesson21-processor"
  role          = aws_iam_role.web_instance.arn  # Implicit: aws_iam_role.web_instance
  handler       = "index.handler"
  runtime       = "nodejs18.x"

  vpc_config {
    subnet_ids         = [aws_subnet.public_a.id]    # Implicit: aws_subnet.public_a
    security_group_ids = [aws_security_group.web.id] # Implicit: aws_security_group.web
  }

  # Even though security group ID is referenced above, the VPC itself must
  # be fully configured with routing before Lambda can reach the internet
  # This explicit dependency ensures routing is in place before Lambda is created
  depends_on = [
    aws_route_table_association.public_a,  # Route table must be associated first
    aws_route_table_association.public_b
  ]
}
$ terraform plan

Terraform will perform the following actions:

  # aws_route_table.public will be created
  + resource "aws_route_table" "public" {
      + vpc_id = (known after apply)
      + route = [{ cidr_block = "0.0.0.0/0", gateway_id = (known after apply) }]
    }

  # aws_route_table_association.public_a will be created
  # aws_route_table_association.public_b will be created

  # aws_lambda_function.processor will be created
  + resource "aws_lambda_function" "processor" {
      + function_name = "lesson21-processor"
      # depends_on = [aws_route_table_association.public_a, aws_route_table_association.public_b]
      # Lambda will not start until BOTH route table associations complete
    }

Plan: 4 to add, 0 to change, 0 to destroy.

# Creation order:
# 1. aws_route_table.public (after VPC and IGW)
# 2. aws_route_table_association.public_a + public_b (in parallel, after route table)
# 3. aws_lambda_function.processor (after BOTH associations — because depends_on)

What just happened?

  • The route table associations complete before Lambda starts. Without depends_on, Terraform might create the Lambda function concurrently with the route table associations — the Lambda VPC attachment would succeed, but the Lambda might not have proper routing on first invocation. The explicit dependency ensures routing is fully configured.
  • Both associations must complete before Lambda starts. The depends_on list contains both associations. Terraform waits for all items in the list before proceeding. If you need routing for both subnets before Lambda starts, both must be in the list.
  • The two route table associations still run in parallel. They both depend on the route table, but not on each other. Terraform creates them simultaneously after the route table is ready. The Lambda function is the only resource that waits for both.

terraform graph — Visualising Dependencies

terraform graph outputs the dependency graph in DOT format — a text-based graph description language. Pipe it into Graphviz to generate a visual diagram. This is invaluable when debugging unexpected creation orders or trying to understand why a complex configuration takes so long to apply.

New terms:

  • DOT format — a plain-text graph description language. Nodes are declared by name. Edges are declared with ->. dot -Tpng graph.dot -o graph.png renders a PNG image.
  • Graphviz — an open-source graph visualisation tool. Install with brew install graphviz on macOS or apt install graphviz on Linux. Renders DOT files into images.
  • terraform graph -type=plan — generates the graph for the planned changes only — not all resources in state. More focused than the full graph for large configurations.
# Generate the full dependency graph in DOT format
terraform graph

# Generate the graph for planned changes only
terraform graph -type=plan

# Render to an image using Graphviz (requires graphviz installed)
terraform graph | dot -Tpng -o dependency-graph.png

# Or use the online viewer — paste DOT output at dreampuf.github.io/GraphvizOnline
terraform graph | pbcopy   # macOS: copies to clipboard
$ terraform graph

digraph {
        compound = "true"
        newrank = "true"
        subgraph "root" {
                "[root] aws_iam_instance_profile.web (expand)" -> "[root] aws_iam_role.web_instance (expand)"
                "[root] aws_iam_role.web_instance (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/aws\"]"
                "[root] aws_instance.web (expand)" -> "[root] aws_iam_instance_profile.web (expand)"
                "[root] aws_instance.web (expand)" -> "[root] aws_internet_gateway.main (expand)"
                "[root] aws_instance.web (expand)" -> "[root] aws_security_group.web (expand)"
                "[root] aws_instance.web (expand)" -> "[root] aws_subnet.public_a (expand)"
                "[root] aws_internet_gateway.main (expand)" -> "[root] aws_vpc.main (expand)"
                "[root] aws_lambda_function.processor (expand)" -> "[root] aws_route_table_association.public_a (expand)"
                "[root] aws_lambda_function.processor (expand)" -> "[root] aws_route_table_association.public_b (expand)"
                "[root] aws_route_table.public (expand)" -> "[root] aws_internet_gateway.main (expand)"
                "[root] aws_route_table.public (expand)" -> "[root] aws_vpc.main (expand)"
                "[root] aws_route_table_association.public_a (expand)" -> "[root] aws_route_table.public (expand)"
                "[root] aws_route_table_association.public_a (expand)" -> "[root] aws_subnet.public_a (expand)"
                "[root] aws_subnet.public_a (expand)" -> "[root] aws_vpc.main (expand)"
                "[root] aws_subnet.public_b (expand)" -> "[root] aws_vpc.main (expand)"
                "[root] aws_security_group.web (expand)" -> "[root] aws_vpc.main (expand)"
                "[root] aws_vpc.main (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/aws\"]"
        }
}

What just happened?

  • The DOT output shows every dependency edge as an arrow. "[root] aws_instance.web" -> "[root] aws_internet_gateway.main" is the explicit dependency we set with depends_on. "[root] aws_instance.web" -> "[root] aws_subnet.public_a" is the implicit dependency from the subnet_id reference. Both types of dependency appear identically in the graph.
  • Rendering this with Graphviz shows which resources are on the critical path. Resources with many arrows pointing to them — like aws_vpc.main — are blockers. Everything waits for them. Resources with no arrows from others can be created at any time. The visual immediately shows what to optimise if an apply is taking too long.
  • The provider appears as the root of every chain. Every resource ultimately depends on the provider being initialised. This is why terraform init must run before plan or apply — the provider must be downloaded before any resources can be created.

Dependency Cycles — When the Graph Cannot Be Built

A dependency cycle exists when resource A depends on resource B which depends on resource A. Terraform requires a directed acyclic graph — no cycles allowed. When a cycle exists, Terraform cannot determine creation order and fails with a cycle error at plan time.

Cycles almost always happen in two situations: a security group that references its own ID in an ingress rule, or two resources that each need the other to exist first. The fix is always the same — break the cycle by extracting the circular dependency into a separate resource.

New terms:

  • aws_security_group_rule — a separate resource for adding individual rules to an existing security group. Using this instead of inline ingress blocks breaks security group self-reference cycles. It also allows security groups to reference each other — SG A allows traffic from SG B, without creating a cycle.
  • self-referencing security group — a common pattern where a security group allows inbound traffic from itself — for cluster communication within the same security group. This cannot be expressed with an inline ingress block because the security group would need its own ID to exist before it is created. aws_security_group_rule with self = true solves this without creating a cycle.
# ── CYCLE EXAMPLE — What NOT to do ───────────────────────────────────────────

# This creates a dependency cycle — do not use this pattern
# resource "aws_security_group" "bad_example" {
#   name   = "cycle-example"
#   vpc_id = aws_vpc.main.id
#
#   ingress {
#     from_port       = 0
#     to_port         = 0
#     protocol        = "-1"
#     security_groups = [aws_security_group.bad_example.id]  # References ITSELF
#     # Terraform cannot create this: needs the SG ID to create the SG
#   }
# }
# Error: Cycle: aws_security_group.bad_example -> aws_security_group.bad_example

# ── CORRECT PATTERN — Break the cycle with aws_security_group_rule ───────────

# Create the security group WITHOUT the self-referencing rule
resource "aws_security_group" "cluster" {
  name        = "lesson21-cluster-sg"
  description = "Security group for cluster nodes"
  vpc_id      = aws_vpc.main.id  # Implicit dependency on VPC

  egress {
    description = "All outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "lesson21-cluster-sg", ManagedBy = "Terraform" }
}

# Add the self-referencing rule AFTER the security group exists
# aws_security_group_rule depends on the SG being created first
# so there is no cycle — the SG exists, then the rule references it
resource "aws_security_group_rule" "cluster_self_ingress" {
  type              = "ingress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  security_group_id = aws_security_group.cluster.id  # Implicit: cluster SG exists first

  # self = true allows traffic from any resource that also uses this security group
  # This is the cluster communication pattern — nodes can talk to each other
  self = true

  description = "Allow all traffic within the cluster security group"
}
$ terraform apply

  # aws_security_group.cluster will be created (no self-reference — no cycle)
  + resource "aws_security_group" "cluster" {
      + name = "lesson21-cluster-sg"
      + egress = [{ cidr_blocks = ["0.0.0.0/0"], protocol = "-1" }]
    }

  # aws_security_group_rule.cluster_self_ingress will be created (after SG exists)
  + resource "aws_security_group_rule" "cluster_self_ingress" {
      + security_group_id = (known after apply)  # Resolved after cluster SG is created
      + self              = true
      + type              = "ingress"
    }

Plan: 2 to add, 0 to change, 0 to destroy.

  Enter a value: yes

aws_security_group.cluster: Creating...
aws_security_group.cluster: Creation complete [id=sg-0cluster123]

# Rule is created after the security group — the cycle is broken
aws_security_group_rule.cluster_self_ingress: Creating...
aws_security_group_rule.cluster_self_ingress: Creation complete

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

What just happened?

  • The cycle was broken by separating the rule into its own resource. The security group is created first — no self-reference in its body. Then the separate aws_security_group_rule resource references the security group's ID. The dependency is one-directional: rule → security group. No cycle.
  • self = true is the correct way to express cluster communication. It allows inbound traffic from any resource that is also a member of this security group. This is used for EKS node groups, ECS clusters, RDS read replicas — any scenario where nodes of the same tier need to communicate directly.
  • Cycles always produce clear error messages at plan time. Terraform names every resource involved in the cycle. If you see "Cycle:" in a plan error, follow the chain of resource references listed — the cycle is broken at the point where the two-way reference exists.

Common Mistakes

Using depends_on when an attribute reference would work

Every explicit depends_on creates a hard sequential dependency — resource B cannot start until resource A is complete. Attribute references allow Terraform more flexibility — it can start resource B as soon as the specific attribute it needs from A is available, even if A is still doing other work. Overusing depends_on serialises operations that could run in parallel, making applies significantly slower in large configurations.

Hardcoding IDs instead of using references

Writing vpc_id = "vpc-0abc123" instead of vpc_id = aws_vpc.main.id eliminates the implicit dependency. Terraform has no way to know that the hardcoded ID belongs to a resource it manages. The subnet might be created before the VPC in some edge cases, and on destroy the VPC might be attempted before the subnet is removed. Always use reference expressions to connect resources you manage.

Putting depends_on on a module when only some resources inside need it

A depends_on on a module block applies to every resource inside the module. If only one resource inside the module needs the explicit dependency, adding depends_on to the module forces all other resources in the module to wait too — even those that have no real dependency. Move the depends_on to the specific resource inside the module if possible.

Use terraform graph before debugging creation order issues

Before adding a depends_on to fix an ordering problem, run terraform graph | dot -Tpng -o graph.png and look at what is already connected. Often the dependency you think is missing is already there — the real problem is something else. Seeing the full graph visually reveals missing connections, unnecessary dependencies, and critical path bottlenecks in seconds that would take minutes to trace through the configuration manually.

Practice Questions

1. When resource A references aws_vpc.main.id in its configuration, what type of dependency does Terraform automatically create?



2. A security group needs to allow inbound traffic from itself — for cluster communication. What resource type breaks the self-reference cycle?



3. Which command outputs the dependency graph in DOT format for visualisation with Graphviz?



Quiz

1. How does Terraform decide which resources to create in parallel?


2. When should you use depends_on instead of an attribute reference to express a dependency?


3. Terraform fails at plan time with an error message that starts with "Cycle:". What does this mean?


Up Next · Lesson 22

Count and for_each

You have used both. Lesson 22 goes deep — the edge cases that bite teams in production, converting between count and for_each without destroying resources, and the patterns for multi-environment deployments with a single resource block.