How to Fix Terraform 'Cycle in Dependency Graph' Between Module and Resource
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 15–45 mins
TL;DR
- What broke: Terraform's DAG (Directed Acyclic Graph) resolver detected a circular reference — resource A depends on module B, and module B depends on resource A, creating an unresolvable execution order.
- How to fix it: Identify the circular reference chain using
terraform graph | dot -Tsvg, then break the cycle by extracting shared values into locals/variables, removing implicitdepends_onchains, or restructuring module outputs. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your failing
.tffiles and get a dependency-safe rewrite without sending your state or secrets anywhere.
The Incident (What Does the Error Mean?)
Raw error output from terraform plan or terraform apply:
Error: Cycle in dependency graph
Cycle: module.networking.aws_security_group.this, aws_instance.app, module.networking
Terraform detected a cycle in the dependency graph. This means Terraform
cannot determine the correct order in which to create, update, or destroy
resources. The cycle must be broken before Terraform can proceed.
Immediate consequence: terraform plan and terraform apply both abort at the graph-resolution phase — before a single API call is made. Your pipeline is completely blocked. No partial apply occurs, but if this surfaces mid-refactor on an existing workspace, your state is locked and your team is dead in the water.
Terraform builds a DAG to determine execution order. Every edge in that graph must flow in one direction. The moment a module references a resource that itself references that module's output (directly or transitively), the graph becomes cyclic and Terraform's graph walker throws a hard stop.
The Attack Vector / Blast Radius
This is not a runtime error — it is a compile-time graph resolution failure. The blast radius depends on where in your module hierarchy the cycle lives:
- Root module cycle: Entire workspace is unplanned. Zero infrastructure changes can be applied. CI/CD pipeline fails on every run.
- Child module cycle: Any workspace that calls this module is broken. If this is a shared module in a private registry, every team consuming it is blocked simultaneously.
- Transitive cycles are the most dangerous. You change module output
X, which is consumed by resourceY, which passes an ARN back into the same module via a variable — the cycle is three hops deep andterraform graphis the only reliable way to visualize it.
Common triggers that engineers miss:
depends_onon a module that itself outputs a value used by the depending resource.- Security group circular references —
aws_security_group_ruleresources that reference each other's IDs across module boundaries. - IAM role + instance profile loops — an EC2 module that takes an IAM role ARN, where the IAM role's trust policy references an instance output from that same EC2 module.
- Data sources misused as outputs — using a
datablock inside a module to look up a resource that hasn't been created yet, where that resource depends on the module.
How to Fix It
Step 0: Visualize the Cycle First
# Requires graphviz installed
terraform graph 2>&1 | grep -A 20 'Cycle'
# Full visual graph — open cycle.svg in a browser
terraform graph | dot -Tsvg > cycle.svg
The terraform graph output will show you the exact node names forming the cycle. Do not guess — read the graph.
Basic Fix: Break the Implicit Dependency
The most common cause is a resource outside a module using depends_on pointing to a module, while that module internally references an output from that same resource.
# main.tf — BROKEN: aws_instance depends on module.networking,
# but module.networking takes aws_instance.app.id as a variable input
- resource "aws_instance" "app" {
- ami = "ami-0abcdef1234567890"
- instance_type = "t3.medium"
- subnet_id = module.networking.subnet_id
-
- depends_on = [module.networking] # <-- explicit depends_on creates the cycle
- }
-
- module "networking" {
- source = "./modules/networking"
- instance_id = aws_instance.app.id # <-- module needs instance ID = cycle
- }
+ # FIX: Decouple by removing the circular variable.
+ # If networking needs the instance ID for a security group rule,
+ # create that rule OUTSIDE the module, after the instance exists.
+
+ resource "aws_instance" "app" {
+ ami = "ami-0abcdef1234567890"
+ instance_type = "t3.medium"
+ subnet_id = module.networking.subnet_id
+ # depends_on removed — implicit dependency via subnet_id attribute reference is sufficient
+ }
+
+ module "networking" {
+ source = "./modules/networking"
+ # instance_id removed from module inputs
+ }
+
+ # Security group rule that needed instance_id now lives at root scope
+ resource "aws_security_group_rule" "allow_app" {
+ type = "ingress"
+ from_port = 8080
+ to_port = 8080
+ protocol = "tcp"
+ security_group_id = module.networking.app_sg_id
+ source_security_group_id = aws_instance.app.security_groups[0] # reference is safe here
+ }
Enterprise Best Practice: Restructure with Layered Module Architecture
The root cause of most module cycles is over-coupling — a module trying to both create infrastructure AND consume outputs from resources it's supposed to precede. The fix is a layered dependency pattern: split into foundation, compute, and policy layers with clean unidirectional data flow.
# ANTI-PATTERN: Single monolithic module with bidirectional references
- module "app_stack" {
- source = "./modules/app_stack"
- vpc_id = aws_vpc.main.id
- instance_arn = aws_instance.app.arn # app_stack creates the instance AND needs its ARN
- }
# ENTERPRISE FIX: Three-layer separation
# Layer 1 — Network (no compute dependencies)
+ module "network" {
+ source = "./modules/network"
+ vpc_cidr = var.vpc_cidr
+ }
+
# Layer 2 — Compute (depends on network outputs only)
+ module "compute" {
+ source = "./modules/compute"
+ subnet_id = module.network.private_subnet_id
+ sg_id = module.network.app_sg_id
+ }
+
# Layer 3 — Policy/IAM (consumes compute outputs, no circular refs)
+ module "iam_bindings" {
+ source = "./modules/iam_bindings"
+ instance_arn = module.compute.instance_arn
+ role_name = module.compute.iam_role_name
+ }
Key rules for the enterprise pattern:
- Module outputs flow downward only (network → compute → policy).
- No module at layer N may accept an input from a module at layer N or above.
- Use
localsto compose values from multiple modules at root scope — never pass them back into a module that was already in the chain. - If two resources genuinely need each other (e.g., two security groups with mutual ingress rules), use
aws_security_group_ruleresources at root scope rather than embedding rules inside theaws_security_groupresource block — this is also the AWS-recommended pattern to avoid the same cycle in the provider itself.
# Security group self-referencing cycle — common AWS pattern mistake
- resource "aws_security_group" "app" {
- name = "app-sg"
- vpc_id = var.vpc_id
-
- ingress {
- from_port = 0
- to_port = 0
- protocol = "-1"
- security_groups = [aws_security_group.db.id] # cycle if db references app
- }
- }
+ resource "aws_security_group" "app" {
+ name = "app-sg"
+ vpc_id = var.vpc_id
+ # No inline ingress/egress rules — defined separately below
+ }
+
+ resource "aws_security_group_rule" "app_from_db" {
+ type = "ingress"
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ security_group_id = aws_security_group.app.id
+ source_security_group_id = aws_security_group.db.id
+ }
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
Do not rely on terraform plan in your pipeline to catch cycles — by then your PR is already merged or your engineer is blocked. Shift this left.
1. terraform validate as a Pre-Commit Hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.5
hooks:
- id: terraform_validate
- id: terraform_tflint
terraform validate catches graph cycles without requiring provider credentials. It runs in under 2 seconds and will block the commit before the code ever reaches CI.
2. TFLint with Dependency Rules
# .tflint.hcl
plugin "terraform" {
enabled = true
preset = "recommended" # includes module structure checks
}
3. OPA Policy — Enforce Unidirectional Module Data Flow
# policies/no_module_cycles.rego
package terraform.module_cycles
# Deny any module variable input that references an output
# from a resource that the same module creates
deny[msg] {
module_call := input.configuration.root_module.module_calls[name]
some key
val := module_call.expressions[key]
# Check if the expression references a resource managed by this module
contains(val.references[_], concat(".", ["module", name]))
msg := sprintf(
"Module '%s' input '%s' creates a circular reference back to itself.",
[name, key]
)
}
Run via Conftest in your pipeline:
terraform show -json plan.tfplan | conftest test --policy ./policies -
4. Checkov Graph Check
checkov -d . --check CKV_TF_1 --framework terraform
# CKV_TF_1 enforces module source pinning which indirectly prevents
# unpinned module versions introducing new cyclic dependencies silently
5. GitHub Actions Gate
# .github/workflows/tf-validate.yml
name: Terraform Graph Validation
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~1.7"
- run: terraform init -backend=false
- run: terraform validate
- run: terraform graph > /dev/null # exits non-zero if cycle detected
name: Graph cycle check
The terraform graph command exits with a non-zero code on cycle detection — this makes it a reliable, zero-dependency gate in any CI system.