Terraform builds a directed acyclic graph of resources and walks it. A cycle means A must exist before B and B must exist before A, which cannot be satisfied, so the plan fails before anything runs.
The error lists every resource in the loop:
Error: Cycle: aws_security_group.app, aws_security_group.db
Pattern 1: Security groups referencing each other
By far the most common. The app group allows traffic to the database group, and the database group allows traffic from the app group:
resource "aws_security_group" "app" {
name = "app"
egress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.db.id] # needs db
}
}
resource "aws_security_group" "db" {
name = "db"
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id] # needs app
}
}
Each group's definition depends on the other's id. Cycle.
The fix is to define the groups empty and attach the rules separately:
resource "aws_security_group" "app" {
name = "app"
vpc_id = var.vpc_id
}
resource "aws_security_group" "db" {
name = "db"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_egress_rule" "app_to_db" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.db.id
from_port = 5432
to_port = 5432
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 5432
to_port = 5432
ip_protocol = "tcp"
}
Now both groups are created first with no dependencies, then both rules. No cycle.
Note the newer aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule replace the older aws_security_group_rule, and are worth preferring: one rule per resource makes plans readable and avoids the perpetual-diff problems the older resource had.
Do not mix inline blocks and rule resources on the same group. Terraform fights itself: the inline blocks describe the complete rule set, so it removes rules added by the separate resources on every apply, and the next apply adds them back. If you use rule resources, the group must have no inline ingress or egress blocks at all.
Pattern 2: An unnecessary depends_on
resource "aws_iam_role" "lambda" {
name = "lambda-exec"
assume_role_policy = data.aws_iam_policy_document.assume.json
depends_on = [aws_lambda_function.api] # wrong direction
}
resource "aws_lambda_function" "api" {
role = aws_iam_role.lambda.arn # genuinely needs the role
}
The function legitimately depends on the role through role = .... The added depends_on points the other way and closes the loop.
Terraform infers dependencies from expression references automatically. An explicit depends_on is only needed for dependencies it cannot see, such as an IAM policy that must exist before an API call the provider makes implicitly. Adding it "to be safe" is how most of these cycles get created.
Remove it and let the reference do the work.
Pattern 3: Modules depending on each other
module "network" {
source = "./modules/network"
cluster_id = module.cluster.id
}
module "cluster" {
source = "./modules/cluster"
subnet_ids = module.network.subnet_ids
}
Terraform treats a module as a unit in the graph, so this is a cycle even if no individual resource pair is circular.
Usually the modules are drawn at the wrong boundary. Move the one resource that creates the back-reference out into the root, or into a third module that depends on both:
module "network" { source = "./modules/network" }
module "cluster" {
source = "./modules/cluster"
subnet_ids = module.network.subnet_ids
}
resource "aws_ec2_tag" "network_cluster" {
resource_id = module.network.vpc_id
key = "cluster"
value = module.cluster.id
}
Dependencies flow one way and the cycle is gone.
Seeing the graph
terraform graph -type=plan > graph.dot
dot -Tsvg graph.dot -o graph.svg
Needs Graphviz. For a large configuration the picture is unreadable, so narrow it:
terraform graph -type=plan | grep -E "aws_security_group|aws_instance"
In practice the error message names the whole loop, and reading the resources it lists for a mutual reference is faster than rendering anything.
Cycles that only appear on destroy
terraform plan -destroy
Destroy reverses the graph, and a configuration that applies cleanly can have a cycle in reverse. create_before_destroy is the usual trigger, because it changes the ordering constraints:
lifecycle {
create_before_destroy = true
}
When one resource in a dependency chain has it and another does not, the ordering can become unsatisfiable. The rule is that create_before_destroy has to propagate: if a resource has it, everything it depends on generally needs it too. Terraform's documentation is explicit that mixing them within a dependency chain causes exactly this.
A checklist
- Read the resource list in the error. That is the loop.
- Two security groups → move the rules into separate rule resources.
- Never mix inline
ingress/egressblocks with rule resources on one group. - Look for a
depends_onpointing the opposite way to a real reference, and delete it. - Modules referencing each other → move the back-reference to the root.
terraform graph -type=plan | grep <resource>when the loop is not obvious.- Cycle only on destroy → look at
create_before_destroyin that chain. - Apply
create_before_destroyconsistently across a dependency chain, not to one member.
Frequently Asked Questions
What causes a cycle error in Terraform?
Two or more resources that each need the other to exist first. Terraform builds a directed acyclic graph from the references in your expressions plus any explicit depends_on, and walks it in dependency order. A cycle makes that order impossible to compute, so the plan fails before anything is created. The error lists every resource in the loop, and the cause is almost always either a mutual attribute reference or a depends_on pointing against an existing reference.
How do I fix two security groups that reference each other?
Define both groups with no inline rules, then create the rules as separate aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources. The groups are then created first with no dependencies on each other, and the rules afterwards, which breaks the loop. The important caveat is not to mix the two styles: a group with inline ingress or egress blocks treats those as the authoritative complete set, so it will remove rules created by separate resources on every apply.
Should I use depends_on to be safe?
No. Terraform infers dependencies from every reference in your expressions, so role = aws_iam_role.lambda.arn already creates the ordering. Adding depends_on on top of that is redundant at best, and when pointed the wrong way it creates exactly the cycle you are trying to avoid. Reserve it for dependencies Terraform genuinely cannot see, such as an IAM policy that must exist before a provider makes an implicit API call, and remove any you cannot justify.
Why does a cycle only appear during terraform destroy?
Because destroy walks the graph in reverse, and the reversed ordering constraints are not always satisfiable even when the forward ones are. The usual trigger is create_before_destroy, which changes how Terraform orders replacement. When one resource in a dependency chain has it set and the resources it depends on do not, the combined constraints can become impossible. The rule is to propagate it: if a resource uses create_before_destroy, everything it depends on generally needs it too.
How do I visualise the dependency graph?
terraform graph -type=plan writes the graph in Graphviz DOT format, which dot -Tsvg graph.dot -o graph.svg renders. For anything beyond a small configuration the full picture is too dense to read, so filter it first by piping through grep for the resource types named in the error. In practice the error message already lists the whole cycle, so scanning those specific resources for a mutual reference is usually faster than rendering a diagram.