Skip to main content
RunBook Academy

TerraformVIII · Dependencies and the Resource GraphDependencies

Dependency Cycles: Detection and Resolution

Intermediate⏱ ~16 minbash

What you'll learn

  • Explain what a dependency cycle is and why Terraform refuses to plan one
  • Recognise the common shapes of cycles (A to B to A, module self-reference, symmetric relationships)
  • Break a cycle by moving the relationship into its own resource, deleting the incidental reference, or computing the shared value from an input
  • Read the single-line `Error: Cycle:` diagnostic and identify which edge to remove

Prerequisites

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.

A dependency cycle is a closed loop in the resource graph: A depends on B, B depends on C, C depends on A. There is no node that can be visited first; there is no topological order; the graph is not a DAG any more.

Terraform refuses to plan a configuration that produces a cycle. The error is one line, and it names every address in the loop:

╷
│ Error: Cycle: aws_security_group.app, aws_security_group.alb
│
│
╵

The error is raised during graph construction, before the planner runs, so every command that builds a graph reports it: terraform validate, terraform plan, terraform apply, and terraform graph. There is no source location on the diagnostic, because the cycle is a property of the graph rather than of any one line of configuration.

This lesson is about what causes cycles, how to recognise them in the graph, and how to break them.

What a cycle looks like

The shortest cycle has two nodes and two edges, and the canonical production example is a pair of security groups that reference each other. The load balancer’s group has to allow egress to the application’s group; the application’s group has to allow ingress from the load balancer’s group:

resource "aws_security_group" "alb" {
  name   = "alb"
  vpc_id = var.vpc_id

  egress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }
}

resource "aws_security_group" "app" {
  name   = "app"
  vpc_id = var.vpc_id

  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }
}

The edges:

  • aws_security_group.alb -> aws_security_group.app (via the egress rule’s security_groups)
  • aws_security_group.app -> aws_security_group.alb (via the ingress rule’s security_groups)

Neither group can be created first, because each one needs the other’s ID as an argument. terraform validate rejects the configuration before a plan is attempted:

╷
│ Error: Cycle: aws_security_group.app, aws_security_group.alb
│
│
╵

The fix is the one AWS’s own model implies: a security group and a rule are separate objects. Move both rules out into aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources (or the older aws_security_group_rule). The two groups are then created first, with no edges between them, and the rules are created afterwards referencing both:

resource "aws_security_group" "alb" {
  name   = "alb"
  vpc_id = var.vpc_id
}

resource "aws_security_group" "app" {
  name   = "app"
  vpc_id = var.vpc_id
}

resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
  security_group_id            = aws_security_group.alb.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = 8080
  to_port                      = 8080
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.alb.id
  from_port                    = 8080
  to_port                      = 8080
  ip_protocol                  = "tcp"
}

Both rule resources depend on both groups; neither group depends on anything. The graph is a DAG again.

Common shapes of cycles

1. Symmetric relationships

The classic shape, and the one in the worked example above: two resources need to know each other’s IDs at create time. The same problem turns up in VPC peering, where the accepter needs the peering connection’s ID and the connection needs the accepter’s VPC ID, and in cross-region replication, cross-account trust policies, and multi-party service meshes.

2. Module self-reference

A module that consumes its own output:

module "app" {
  source = "./modules/app"

  vpc_id     = module.app.vpc_id
  subnet_ids = module.app.subnet_ids
}

The module’s vpc_id output is computed by a resource inside the module; the module call passes that output back to the module as an input. The cycle runs through the module’s input variable, the resource, and the output. Terraform expands those into separate graph nodes and names all three:

╷
│ Error: Cycle: module.app.var.vpc_id (expand), module.app.aws_vpc.main, module.app.output.vpc_id (expand)
│
│
╵

The (expand) suffix marks a node that stands in for a variable or an output before count/for_each expansion, and it is a reliable sign that the loop passes through a module boundary.

3. A local that loops back into the resource it reads

A local derived from a resource, then fed back into that same resource, is a two-node cycle. It is easy to write, because the local reads like a convenience rather than a dependency:

locals {
  app_endpoint = "https://${aws_lb.app.dns_name}"
}

resource "aws_lb" "app" {
  name               = "app"
  load_balancer_type = "application"
  subnets            = var.subnet_ids

  tags = {
    Endpoint = local.app_endpoint
  }
}

local.app_endpoint reads aws_lb.app.dns_name, so the local depends on the load balancer. The load balancer’s tags read local.app_endpoint, so the load balancer depends on the local. Local values are graph nodes like any other, and the error names this one alongside the resource:

╷
│ Error: Cycle: aws_lb.app, local.app_endpoint
│
│
╵

The same shape appears when a count or for_each expression reads a local that reads a resource created in the same apply.

4. A depends_on that points back

An implicit edge in one direction and an explicit depends_on in the other is a cycle, and it is easy to write because the two directions live in different resource blocks:

resource "aws_instance" "web" {
  ami           = "ami-0e1bed4f"
  instance_type = "t3.medium"
  subnet_id     = aws_subnet.public.id
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"

  depends_on = [aws_instance.web]
}

The instance reads the subnet’s ID, so there is an edge from the instance to the subnet. The depends_on adds an edge back. The error names both:

╷
│ Error: Cycle: aws_subnet.public, aws_instance.web
│
│
╵

depends_on is the most common way teams introduce a cycle into a configuration that previously worked, because it adds an edge that no expression in the file makes visible.

5. A module and its caller referencing each other

The root passes a resource attribute into a module, and the same resource reads one of that module’s outputs:

module "net" {
  source = "./modules/net"
  seed   = aws_kms_key.encryption.arn
}

resource "aws_kms_key" "encryption" {
  description = module.net.key_description
}

The loop runs through the module’s input variable and back out through its output:

╷
│ Error: Cycle: module.net.output.key_description (expand), aws_kms_key.encryption, module.net.var.seed (expand), module.net.aws_ssm_parameter.seed
│
│
╵

The fix is the same as for a module that consumes its own output: one of the two values has to come from somewhere that is not the other side of the loop.

How to break a cycle

Strategy 1: Move the relationship into its own resource

When two resources each need something the other computes, take the relationship away from both and give it to a third resource. This is the same move the worked example above makes with the security group rules, and it generalises.

An elastic IP and the instance it points at is the second most common place it comes up. The EIP names the instance it attaches to; the instance’s user_data needs the address:

resource "aws_eip" "web" {
  instance = aws_instance.web.id
}

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"
  user_data     = "echo ${aws_eip.web.public_ip} > /etc/public-ip"
}

aws_eip.web reads the instance ID; aws_instance.web reads the EIP’s address. Neither can be created first:

╷
│ Error: Cycle: aws_eip.web, aws_instance.web
│
│
╵

The AWS provider has a resource for exactly this: aws_eip_association owns the attachment, so the EIP no longer has to name the instance.

resource "aws_eip" "web" {
  domain = "vpc"
}

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"
  user_data     = "echo ${aws_eip.web.public_ip} > /etc/public-ip"
}

resource "aws_eip_association" "web" {
  allocation_id = aws_eip.web.id
  instance_id   = aws_instance.web.id
}

The EIP now depends on nothing, the instance depends on the EIP, and the association depends on both. The edges all point one way and the graph is a DAG.

The general rule: when the loop is “A must know about B and B must know about A”, look for a provider resource that represents the relationship itself. Attachments, associations, memberships, and rules are almost always modelled as their own resource type, precisely because the API has the same ordering problem Terraform does.

Strategy 2: Remove the incidental reference, keep the ordering

depends_on never breaks a cycle. It only adds edges, so adding one to a graph that already has a loop can only make the loop larger. What breaks the cycle is deleting one of the references.

The reason this strategy has a name is that deleting a reference usually deletes an ordering guarantee you still want. depends_on is how you get the ordering back, in the direction that does not close the loop. Here the instance had a tag interpolating aws_vpc.main.id, and the VPC’s own configuration referenced the instance:

resource "aws_instance" "web" {
  ami           = "ami-0e1bed4f"
  instance_type = "t3.medium"

  # The tag no longer interpolates aws_vpc.main.id, so the data
  # edge is gone; depends_on keeps the ordering.
  tags = {
    Name = "web-01"
  }

  depends_on = [aws_vpc.main]
}

Check the direction before you add it. A depends_on pointing back along an existing implicit edge is one of the most common ways to create a cycle in the first place, as shape 4 above shows.

Strategy 3: Compute the value from inputs, not from the resource

When the loop runs through a local, the fix is to derive the local from something that is not on the other side of it. Shape 3 above becomes acyclic the moment the endpoint comes from an input rather than from the load balancer’s own attribute:

locals {
  app_endpoint = "https://${var.domain_name}"
}

resource "aws_lb" "app" {
  name               = "app"
  load_balancer_type = "application"
  subnets            = var.subnet_ids

  tags = {
    Endpoint = local.app_endpoint
  }
}

local.app_endpoint now reads var.domain_name, which no resource produces, so the edge from the local to the load balancer is gone.

The same move fixes a count or for_each that reads a resource attribute: put the decision in a variable, so it is known before any resource is created.

resource "aws_s3_bucket" "output" {
  count  = var.enable_output ? 1 : 0
  bucket = "example-output"
}

count reads var.enable_output, not a resource attribute. This also sidesteps the separate “value depends on resource attributes that cannot be determined until apply” error that a resource-derived count produces even when there is no cycle.

Strategy 4: Split the module

When a module consumes its own output, split the module:

Before:
  module.app (produces and consumes vpc_id)

After:
  module.network (produces vpc_id, subnet_ids)
  module.app (consumes vpc_id, subnet_ids from module.network)

The cycle is broken because the consumer is now in a different module, and the producer has no edges back into the consumer.

How Terraform reports a cycle

Cycle detection happens when the graph is built, so terraform validate reports it without contacting any provider. That makes validate the cheapest place to catch one, and the right place to put the CI check. plan, apply, and graph build the same graph and report the same error.

The error format is a single line inside the standard diagnostic box, with no source location attached:

╷
│ Error: Cycle: aws_subnet.public, aws_instance.web
│
│
╵

The addresses are the nodes of the loop, and reading them in order is the fastest way to identify it. Terraform does not tell you which edge to remove; that is the judgement the rest of this lesson is about.

For cycles through modules, the error uses module-qualified addresses and adds an (expand) suffix to the variable and output nodes:

╷
│ Error: Cycle: module.app.var.vpc_id (expand), module.app.aws_vpc.main, module.app.output.vpc_id (expand)
│
│
╵

terraform graph does not draw the loop for you

terraform graph builds the same graph the planner builds, so on a configuration with a cycle it fails with the same error instead of emitting DOT:

╷
│ Error: Cycle: aws_security_group.app (expand), aws_security_group.alb (expand)
│
│
╵

Note the (expand) suffixes: graph reports the pre-expansion nodes. This means you cannot render a picture of a cycle you already have. terraform graph is the tool for reading a graph that is still acyclic — to check that a new edge went where you expected before you introduce a loop:

terraform graph | dot -Tpng > graph.png

terraform graph also advertises a -draw-cycles option that colours cycle edges. It does not help here: the graph has to build before anything can be drawn, so on a configuration with a cycle the command fails with the error above instead of producing DOT. Once a cycle exists, the error line is the only picture you get, and it is enough: it names every node in the loop.

Failure modes

  1. Cycle through module outputs. The error names module.<name>.var.<x> (expand) and module.<name>.output.<y> (expand). Split the module so the producer and the consumer are different modules.
  2. Cycle through two security groups. The error names two aws_security_group addresses and nothing else. Move both rules into separate rule resources.
  3. Cycle through data source references. The error names data source addresses. Replace the data source reference with a variable or a local computed from non-resourced inputs.
  4. Cycle introduced by a depends_on. Nothing in the expressions shows the loop, because one edge is explicit and the other is implicit. Read the depends_on lists on every address the error names; the explicit edge is almost always the incidental one.
  5. Cycle that survives a refactor. The loop is encoded in a local, or in a count or for_each expression that reads one, which depends on a resource being created in the same apply. The error names the local.<name> node alongside the resource. Move the decision to a variable so it is known before any resource exists.

How to validate

# READ-ONLY: builds the graph without contacting a provider.
terraform validate

validate is the check to run in CI. It builds the graph, so it reports a cycle, and it needs no credentials and no state.

Once validate passes, the graph is acyclic and terraform graph becomes useful for reading the edges you just added:

terraform graph -type=plan | grep -E "->" | sort -u

The sort -u deduplicates edges. Read the edge list. Every outgoing edge should point at something the resource actually depends on; an edge you did not intend is the one that will close a loop the next time someone adds a reference in the other direction.

For large configurations, render it:

terraform graph -type=plan | dot -Tpng > graph.png

What to do in production

  • Treat cycles as bugs. The plan is correct; the configuration is wrong.
  • Refactor the configuration to remove the cycle. Strategies 1 through 4 above.
  • Document the cycle in the post-incident review if the cycle caused an incident. The documentation should include the graph before and after the fix.
  • Add a CI check that runs terraform validate on every PR and rejects cycles. The error is unambiguous; the check is cheap.

Verification

  • Run terraform validate and confirm no cycle is reported. This is the check that belongs in CI: it builds the graph without needing credentials or state.
  • Run terraform plan -out=tfplan and confirm the plan succeeds.
  • Run terraform graph -type=plan and read the edge list. It produces DOT only once the graph is acyclic, so a successful run is itself part of the evidence.
  • For each module, confirm the edges are intentional.

Knowledge check · 7 questions

  1. Q1. What does Terraform do when it detects a dependency cycle in the configuration?

  2. Q2. A self-reference inside a `locals` block always creates a cycle.

  3. Q3. `aws_security_group.alb` has an egress rule whose `security_groups` list contains `aws_security_group.app.id`; `aws_security_group.app` has an ingress rule whose `security_groups` list contains `aws_security_group.alb.id`. What is the result?

  4. Q4. Which of the following can break a dependency cycle? (Select all that apply.)

  5. Q5. After a refactor, `terraform plan` fails with `Error: Cycle: module.app.var.vpc_id (expand), module.app.aws_vpc.main, module.app.output.vpc_id (expand)`. The module call passes one of the module's own outputs back in as an input. What is the fix?

  6. Q6. How does Terraform report a cycle in the plan output?

  7. Q7. `terraform validate` catches most dependency cycles before `terraform plan`.

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