Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Terraform By James Joyner IV · · 9 min read

Terraform Error Guide: 'Cycle' dependency cycle detected on plan

Fix Terraform's 'Cycle' error: break circular dependencies between resources, modules, and data sources using indirection, depends_on, and split applies.

  • #terraform
  • #troubleshooting
  • #errors
  • #dependencies

Overview

Terraform builds a directed acyclic graph (DAG) of your resources so it can apply them in dependency order. A Cycle error means two or more nodes depend on each other, directly or transitively, so there is no valid order to create them. Terraform cannot resolve the graph and stops before doing anything.

You will see this on terraform plan or apply:

Error: Cycle: aws_security_group.app, aws_security_group.db

Or a longer chain:

Error: Cycle: aws_iam_role.task, aws_iam_role_policy.task, aws_iam_policy_document.task (expand), aws_iam_role.task

The listed nodes form the loop. The fix is to break the cycle by introducing indirection (a separate resource), removing an unnecessary reference, or splitting the apply — not by adding more depends_on.

Symptoms

  • plan/apply/destroy fail with Error: Cycle: <list of resources>.
  • Two resources reference each other’s attributes (mutual reference).
  • It appears after adding a depends_on, an egress/ingress self-reference, or a module output that loops back.
  • terraform graph shows a back-edge between the named nodes.
terraform plan
Error: Cycle: aws_security_group_rule.app_to_db, aws_security_group.db,
aws_security_group_rule.db_to_app, aws_security_group.app

Common Root Causes

1. Two security groups referencing each other inline

Inline ingress/egress rules that each reference the other group create a mutual dependency.

grep -rn 'security_groups\|source_security_group_id' security.tf
resource "aws_security_group" "app" {
  ingress { security_groups = [aws_security_group.db.id] }
}
resource "aws_security_group" "db" {
  ingress { security_groups = [aws_security_group.app.id] }
}

Each group needs the other to exist first — a classic cycle.

2. A self-referencing IAM role and policy

A role references a policy whose document references the role’s ARN, looping back.

grep -rn 'aws_iam_role\.\|role =' iam.tf
resource "aws_iam_role" "task" {
  assume_role_policy = data.aws_iam_policy_document.assume.json
}
data "aws_iam_policy_document" "assume" {
  statement { resources = [aws_iam_role.task.arn] }   # loops back
}

The role depends on the document, which depends on the role.

3. An unnecessary or over-broad depends_on

A depends_on pointing at something that already depends back on this resource forces a loop.

grep -rn 'depends_on' *.tf
resource "aws_instance" "app" {
  depends_on = [aws_eip.app]
}
resource "aws_eip" "app" {
  instance = aws_instance.app.id   # already depends on app
}

The explicit depends_on reverses an existing implicit dependency.

4. Mutual module dependencies

Module A consumes an output of Module B while B consumes an output of A.

grep -rn 'module\.' *.tf
module "network" { db_sg = module.database.sg_id }
module "database" { vpc_id = module.network.vpc_id }   # cross-loop

If the loop is real (not just two unrelated outputs), the graph cannot order the modules.

5. A data source depending on a resource it gates

A data block that reads a resource which in turn depends on that data creates a cycle.

grep -rn 'data "' *.tf
data "aws_subnet" "selected" { id = aws_subnet.main.id }
resource "aws_subnet" "main" {
  availability_zone = data.aws_subnet.selected.availability_zone
}

6. Count/for_each referencing the resource being counted

A count/for_each whose value depends on an attribute of the same resource set loops.

grep -rn -A1 'count =\|for_each =' *.tf
resource "aws_instance" "node" {
  count = length(aws_instance.node)   # self-referential
}

Diagnostic Workflow

Step 1: Read the cycle membership

terraform plan 2>&1 | grep '^Error: Cycle'

Every node in the loop is listed — these are your only suspects.

Step 2: Visualize the graph

terraform graph | grep -i cycle
terraform graph -draw-cycles | dot -Tsvg > graph.svg

-draw-cycles highlights the back-edge so you can see exactly which reference closes the loop.

Step 3: Find the mutual references

for n in aws_security_group.app aws_security_group.db; do
  echo "== $n =="; grep -rn "${n##*.}" *.tf
done

Locate where each node references the other; one of those references must be broken.

Step 4: Break the cycle with indirection

grep -rn 'ingress\|egress' security.tf

Move inline rules into separate aws_security_group_rule (or aws_vpc_security_group_*_rule) resources so groups are created first and rules wired afterward:

resource "aws_security_group" "app" {}
resource "aws_security_group" "db" {}
resource "aws_security_group_rule" "app_to_db" {
  security_group_id        = aws_security_group.db.id
  source_security_group_id = aws_security_group.app.id
  type = "ingress"; from_port = 5432; to_port = 5432; protocol = "tcp"
}

Step 5: Re-plan to confirm the DAG resolves

terraform plan

A clean plan (no Cycle) confirms the graph is now acyclic.

Example Root Cause Analysis

A plan fails the moment two security groups are wired to each other:

Error: Cycle: aws_security_group.app, aws_security_group.db

Inspecting both groups shows inline rules that reference each other:

grep -rn 'security_groups' security.tf
resource "aws_security_group" "app" {
  egress { security_groups = [aws_security_group.db.id] }
}
resource "aws_security_group" "db" {
  ingress { security_groups = [aws_security_group.app.id] }
}

app needs db.id and db needs app.id — neither can be created first. The fix is to remove the inline rules and create the groups bare, then add the cross-rules as standalone resources.

resource "aws_security_group" "app" {}
resource "aws_security_group" "db" {}

resource "aws_security_group_rule" "app_egress_db" {
  type                     = "egress"
  security_group_id        = aws_security_group.app.id
  source_security_group_id = aws_security_group.db.id
  from_port = 5432; to_port = 5432; protocol = "tcp"
}
resource "aws_security_group_rule" "db_ingress_app" {
  type                     = "ingress"
  security_group_id        = aws_security_group.db.id
  source_security_group_id = aws_security_group.app.id
  from_port = 5432; to_port = 5432; protocol = "tcp"
}

Now both groups are created first, and the rules reference IDs that already exist. terraform plan resolves the graph cleanly.

Prevention Best Practices

  • Wire cross-references between security groups with standalone aws_security_group_rule resources, not inline ingress/egress blocks — this is the single most common cycle.
  • Add depends_on sparingly and only for genuinely hidden dependencies; never to a resource that already depends on the current one.
  • Keep module dependencies one-directional: if A and B truly need each other’s data, lift the shared resource into a parent module and pass it down.
  • Avoid count/for_each expressions that reference attributes of the same resource set.
  • Use terraform graph -draw-cycles early when refactoring large configs to catch loops before they reach CI.
  • For triage, the free incident assistant can take the Cycle: member list and point at the back-edge to break. More patterns in the Terraform guides.

Quick Command Reference

# Read the cycle membership
terraform plan 2>&1 | grep '^Error: Cycle'

# Visualize and highlight the back-edge
terraform graph -draw-cycles | dot -Tsvg > graph.svg

# Find the mutual references for each node
grep -rn '<RESOURCE_NAME>' *.tf

# Common fix: standalone rules instead of inline ingress/egress
grep -rn 'ingress\|egress' security.tf

# Confirm the DAG resolves
terraform plan

Conclusion

Error: Cycle means two or more nodes depend on each other and Terraform’s graph has no valid apply order. The usual root causes:

  1. Two security groups referencing each other via inline rules.
  2. A self-referencing IAM role and policy document.
  3. An unnecessary or reversed depends_on.
  4. Mutually dependent modules.
  5. A data source that gates a resource it also depends on.
  6. A count/for_each referencing its own resource set.

Read the cycle members, use terraform graph -draw-cycles to find the back-edge, and break it with indirection (standalone rules, a shared parent module) rather than more depends_on.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.