Fix the "Cycle" Dependency Error in Terraform
A "Cycle" error means two Terraform resources depend on each other; here is how to find the loop and break it cleanly.
Key takeaways
- A Terraform Cycle error means resources reference each other, so the dependency graph loops and Terraform cannot order them.
- Run terraform graph -type=plan -draw-cycles to see the loop, then cut the backward edge.
- For mutually referencing security groups, move inline rules into separate aws_vpc_security_group_ingress_rule resources.
On this page
Fix Error: Cycle: by finding the reference loop and cutting the edge that points backward. Run terraform graph -type=plan -draw-cycles to color the loop, then restructure. The most common case, two security groups with inline rules pointing at each other, is fixed by moving the rules into separate aws_vpc_security_group_ingress_rule resources.
Verified against
Terraform v1.17.1, hashicorp/aws provider v6.71.0, against the developer.hashicorp.com terraform graph docs and the AWS provider security group rule docs. Checked October 2026.
You run terraform plan, and instead of a plan you get Error: Cycle: followed by a list of resource addresses. No line number, no obvious typo, nothing you can grep for in a single file. It confuses people because the error is about relationships between resources, not the resources themselves.
What a cycle is
Before Terraform applies anything, it builds a dependency graph. Every resource, data source, module, and output becomes a node. Every reference (aws_subnet.main.id, module.network.vpc_id, an explicit depends_on) becomes a directed edge. Terraform then walks that graph in dependency order so it creates things before the things that need them.
That only works if the graph is a directed acyclic graph: no node can, by following edges, eventually reach itself. A "Cycle" error means it can. Resource A depends on B, and B depends on A, either directly or through a chain of other resources. Terraform cannot decide which one to create first, so it refuses to plan.
What the error looks like
The classic version comes from two security groups that reference each other:
Error: Cycle: aws_security_group.a, aws_security_group.b
Longer chains show the full loop, which is your best clue:
Error: Cycle: module.app.aws_iam_role.this,
module.app.aws_iam_role_policy.this,
aws_iam_role.this
Read the list as "each of these depends on the next, and the last one loops back to the first."
Seeing the graph
When the loop runs through more than two resources, generate the graph and look at it:
$ terraform graph -type=plan -draw-cycles | dot -Tsvg > graph.svg
$ open graph.svg # xdg-open on Linux
terraform graph emits the dependency graph in DOT format; piping it through Graphviz's dot gives you an image. Plain terraform graph prints a simplified resource-only view, and -draw-cycles only works together with a -type value, which is why the command above passes -type=plan. With it, Terraform colors the edges that form the cycle. If you do not have Graphviz, even the raw text output shows the edges as "a" -> "b" lines that you can trace by hand. Follow the arrows from any name in the error message and you will find the path that returns to where you started.
Common real causes
A handful of patterns produce almost every cycle you will hit in practice.
Two security groups referencing each other. Group A allows traffic from group B, and group B allows traffic from group A, both with inline rules. Each SG now depends on the other's ID.
A resource referencing its own output. An attribute that feeds back into the same resource's arguments, often introduced by a for_each or count expression that reads from the thing it is building.
Module input/output loops. Module X takes an input from module Y's output, and module Y takes an input from module X's output. The loop is between the module boundaries, not any single resource.
depends_on creating a loop. Someone added an explicit depends_on to be safe, not realizing the target already depends on this resource. Now the edge goes both ways.
IAM role and policy mutual references. A role that references a policy which references the role's ARN back, common when an assume-role policy and an inline policy get tangled.
Breaking the classic security group cycle
Here is the cyclic version. Two groups, each with an inline rule pointing at the other:
# network/security_groups.tf (cyclic)
resource "aws_security_group" "a" {
name = "app-a"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.b.id]
}
}
resource "aws_security_group" "b" {
name = "app-b"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.a.id]
}
}
Group a needs b.id to exist, and group b needs a.id. Neither can be created first. Error: Cycle: aws_security_group.a, aws_security_group.b.
The fix is to stop putting the rules inside the groups. Define the security groups with no inline rules, then attach the rules as separate resources. Now each rule depends on both groups, and the groups depend on nothing but the VPC:
# network/security_groups.tf (fixed)
resource "aws_security_group" "a" {
name = "app-a"
vpc_id = var.vpc_id
}
resource "aws_security_group" "b" {
name = "app-b"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "a_from_b" {
security_group_id = aws_security_group.a.id
referenced_security_group_id = aws_security_group.b.id
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_vpc_security_group_ingress_rule" "b_from_a" {
security_group_id = aws_security_group.b.id
referenced_security_group_id = aws_security_group.a.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
The graph now flows one way: both groups get created, then both rules attach. No node can reach itself. The older aws_security_group_rule resource breaks the cycle the same way, but as of provider v6.71.0 the AWS docs say to avoid both it and inline ingress/egress blocks, because they struggle with multiple CIDR blocks, tags and descriptions. The principle is identical: move the relationship out of the resource and into a resource that depends on both ends.
Warning
Do not mix inline ingress/egress blocks or aws_security_group_rule with the aws_vpc_security_group_*_rule resources on the same security group. The provider docs warn this "may cause rule conflicts, perpetual differences, and result in rules being overwritten." Pick one style per group.
Breaking the other cases
Remove unnecessary depends_on. If resource B already references an attribute of A, delete any explicit depends_on = [A]. The implicit edge is enough, and the explicit one is often what closed the loop.
Restructure module boundaries. When two modules feed each other, one of them is doing too much. Pull the shared resource up to the root module, or into a third module both can depend on, so the dependency runs in one direction. If that third module is shared across teams, pin it the way we describe in versioning Terraform modules in a shared registry.
Split a resource. If a resource references its own output, separate the part that produces the value from the part that consumes it, the same move as the security group rules. Sometimes a data source reading the created resource, referenced later, avoids the self-reference entirely.
After any of these, run terraform graph -type=plan -draw-cycles again and confirm no edges are colored before you re-plan.
For the wider set of failures you will meet, see the Terraform error troubleshooting guide.
Questions people ask
How do I find which resources cause a Terraform cycle?
Start with the addresses in the error: Error: Cycle: lists every node in the loop, in order. For long chains, run terraform graph -type=plan -draw-cycles | dot -Tsvg > graph.svg, which colors the cycle edges. Without Graphviz, read the raw DOT output and follow the "a" -> "b" lines from any listed address until you return to it.
Can depends_on cause a cycle error in Terraform?
Yes, and it is one of the most common causes after a refactor. An explicit depends_on adds an edge even when an attribute reference already implies the opposite direction, which closes the loop. If resource B already reads an attribute of A, delete depends_on = [A] from B, and never point depends_on at something that consumes your outputs.
Why does a cycle appear only when I destroy resources?
Destroy runs the graph in reverse, and create_before_destroy adds extra edges, so a configuration that plans cleanly can still cycle during terraform destroy or a replacement. Graph the destroy path with terraform graph -type=plan-destroy -draw-cycles to see it. The usual fix is the same: split the resource that sits on both sides of the loop.
The call we'd make
Reach for separate rule resources by default. Inline security group rules read nicely in a demo, but the moment two groups need to talk to each other you are one edit away from a cycle. Standalone aws_vpc_security_group_ingress_rule resources cost a few extra lines and make your dependency graph honest: groups first, relationships second. When a cycle does show up, do not guess. Run terraform graph, trace the arrows back to the start, and cut the single edge that should never have pointed backward.
Sources
Cite this article
Kiril Urbonas (2026). Fix the "Cycle" Dependency Error in Terraform. DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/fix-terraform-cycle-error
[Fix the "Cycle" Dependency Error in Terraform](https://www.devopsness.com/blog/fix-terraform-cycle-error) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)
More from DevOps
Explore more articles in this category
15 SRE Interview Questions With Answers, From SLOs to On-Call
SRE interviews test numbers and judgment. Fifteen questions on SLIs, error budgets, incident command, toil, capacity, on-call and postmortems, plus a systems-design scenario, each with the answer that scores and the one that loses the room.
Sign Container Images With Cosign: Keyless GitHub Actions Workflow, SBOM and In-Cluster Checks
One workflow that builds, signs keylessly with GitHub OIDC, attaches an SBOM and SLSA provenance, plus the Kyverno policy that refuses anything unsigned. Copy it, change three names, ship it.
Fix Prometheus High Cardinality: 6 Commands to Find and Drop Bad Series
When Prometheus runs out of memory, one label is almost always to blame. Six commands to find it, the relabel rules that drop it, and the scrape limits that stop it coming back.
You might have missed
Evergreen posts worth revisiting.