The Error
Error: Cycle detected in resource dependencies
aws_security_group.web (expand) -> aws_instance.app (expand) -> aws_security_group.web (expand)
Two or more resources are depending on each other in a circle. Resource A needs Resource B to exist first. But Resource B also needs Resource A. Neither can go first โ so Terraform refuses to apply.
Why This Happens
Before applying any changes, Terraform builds a directed acyclic graph (DAG) of all your resources. Each attribute reference adds an edge. If those edges loop back on themselves, the graph is no longer acyclic โ and the plan fails immediately.
Common causes:
- Two resources each referencing the other's output (e.g., cross-referencing
security_group_idandprivate_ip) - A
depends_onchain that accidentally loops back to its origin - Two modules passing outputs back and forth to each other
- A resource referencing an attribute of itself (less obvious than it sounds)
Step 1: Read the Cycle Path in the Error
Terraform always prints the exact cycle path. Read it before doing anything else:
Error: Cycle detected in resource dependencies
aws_security_group.web (expand)
-> aws_instance.app (expand)
-> aws_security_group.web (expand)
The loop here is clear: aws_security_group.web โ aws_instance.app โ back to aws_security_group.web. That's your starting point. Go straight to those two resources in your code.
Step 2: Visualize the Full Dependency Graph
For complex configs with many resources, a visual graph saves a lot of hunting:
terraform graph | dot -Tsvg > graph.svg
Open graph.svg in any browser. You're looking for arrows pointing in both directions between two nodes. That bidirectional edge is your cycle. Install Graphviz first if you don't have it:
# Ubuntu/Debian
sudo apt install graphviz
# macOS
brew install graphviz
Step 3: Identify the Circular Reference in Code
Here's the classic broken pattern โ a security group and an EC2 instance that each need the other to exist first:
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
# BAD: referencing aws_instance creates a cycle
cidr_blocks = ["${aws_instance.app.private_ip}/32"]
}
}
resource "aws_instance" "app" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
# BAD: this also references the security group above
vpc_security_group_ids = [aws_security_group.web.id]
}
The security group needs the instance's private IP. The instance needs the security group's ID. Neither can be created first. Terraform is stuck.
Step 4: Break the Cycle
Pick the option that fits your situation.
Option A: Remove the bidirectional reference
Most of the time, one of the two references isn't strictly necessary. In the example above, does the security group really need to lock ingress to a specific instance IP? Usually not. Use a CIDR range instead:
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
# FIXED: static CIDR, no reference to aws_instance
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web.id]
}
Option B: Use aws_security_group_rule (separate resource)
Sometimes you genuinely need both resources to know about each other. The trick: create the security group first with no ingress rules, then add the rule as a standalone resource after the instance exists.
resource "aws_security_group" "web" {
name = "web-sg"
# No inline ingress rules here
}
resource "aws_instance" "app" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web.id]
}
# This resource depends on both, but neither depends on it โ no cycle
resource "aws_security_group_rule" "allow_instance" {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["${aws_instance.app.private_ip}/32"]
security_group_id = aws_security_group.web.id
}
The rule resource sits downstream of both. It depends on them โ they don't depend on it. Cycle broken.
Option C: Remove an unnecessary depends_on
Explicit depends_on blocks are a common source of accidental cycles. Terraform already tracks implicit dependencies through attribute references โ you don't need to declare them again:
# Check if this depends_on is creating a loop
resource "aws_s3_bucket_policy" "example" {
bucket = aws_s3_bucket.main.id
policy = data.aws_iam_policy_document.example.json
# The bucket reference above already creates the dependency.
# This explicit depends_on is redundant โ remove it.
depends_on = [aws_s3_bucket.main]
}
Only use depends_on when there's no attribute reference between two resources but one still needs the other to exist first.
Option D: Refactor into separate modules
Cycles that span two modules are an architectural problem. The pattern: module A outputs something that module B needs, and module B outputs something that module A needs. The fix is to extract the shared resource into a third module that both consume:
# Instead of module A outputting to module B and module B outputting back to A,
# create a third "shared" module that owns the shared resource
module "network" {
source = "./modules/network"
# owns VPC, subnets, security groups
}
module "compute" {
source = "./modules/compute"
sg_id = module.network.web_sg_id # one-way dependency only
}
Step 5: Verify the Fix
Run terraform validate first, then terraform plan:
terraform validate
# Expected: Success! The configuration is valid.
terraform plan
# Should produce a clean plan with no cycle errors
Still seeing a cycle error? Re-read the path โ it may now point to a different pair of resources. Repeat Steps 1โ4 for each new cycle until the plan is clean.
Tips to Avoid This in the Future
- Don't mix inline rules with separate rule resources. If you're using
aws_security_group_ruleresources, remove allingressandegressblocks from the parentaws_security_group. Mixing both causes conflicts and cycles. - Sketch the dependency direction before writing code. A quick diagram takes two minutes. Arrows should always flow one way between any two resources โ never both ways.
- Run
terraform graphas you add resources. Catching a cycle at resource #5 is much easier than untangling it at resource #50. - Be careful with module-level
depends_on. It creates an implicit dependency on every single resource inside that module โ not just the ones you intended. That broad sweep can pull in unexpected cycles.

