TerraformVIII · Dependencies and the Resource GraphDependencies
Dependency Cycles: Detection and Resolution
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
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 theegressrule’ssecurity_groups)aws_security_group.app->aws_security_group.alb(via theingressrule’ssecurity_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
- Cycle through module outputs. The error names
module.<name>.var.<x> (expand)andmodule.<name>.output.<y> (expand). Split the module so the producer and the consumer are different modules. - Cycle through two security groups. The error names two
aws_security_groupaddresses and nothing else. Move both rules into separate rule resources. - Cycle through data source references. The error names data
source addresses. Replace the data source reference with a
variable or a
localcomputed from non-resourced inputs. - Cycle introduced by a
depends_on. Nothing in the expressions shows the loop, because one edge is explicit and the other is implicit. Read thedepends_onlists on every address the error names; the explicit edge is almost always the incidental one. - Cycle that survives a refactor. The loop is encoded in a
local, or in acountorfor_eachexpression that reads one, which depends on a resource being created in the same apply. The error names thelocal.<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 validateon every PR and rejects cycles. The error is unambiguous; the check is cheap.
Verification
- Run
terraform validateand 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=tfplanand confirm the plan succeeds. - Run
terraform graph -type=planand 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
Q1. What does Terraform do when it detects a dependency cycle in the configuration?
Q2. A self-reference inside a `locals` block always creates a cycle.
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?
Q4. Which of the following can break a dependency cycle? (Select all that apply.)
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?
Q6. How does Terraform report a cycle in the plan output?
Q7. `terraform validate` catches most dependency cycles before `terraform plan`.
Passing score: 75%. Answers are checked in this browser.