RAPID INCIDENT TRIAGE & ACTION PLAN•Syntax
01. Error Signature
Terraform Module Resource Cycle Debugger
02. Root Cause Vector

A circular dependency between a Terraform module and a resource causes the dependency graph to deadlock, halting all plan and apply opera...

03. Rapid Prescription

Refresh remote state lock, ensure provider version constraints, and audit state drift.

Zero-Trust Client IDE/ Syntax

Terraform Module Resource Cycle Debugger

A circular dependency between a Terraform module and a resource causes the dependency graph to deadlock, halting all plan and apply operations.

SYSTEM STATUS
CREDITS: 0 / 3
source.yaml(63L)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# THIS IS THE LEGACY INFRASTRUCTURE MODULE - DO NOT MODIFY WITHOUT ARCHITECT REVIEW
# TODO: This module is a mess. Refactor after PCI compliance audit next quarter.
# WARNING: This config is used by the pci-secure-zone and my-registry.internal/legacy-api

module "legacy_application" {
  source = "git::https://my-registry.internal/legacy-infra-modules//app?ref=v2.1.3"

  # VULNERABILITY: The module's subnet depends on the security group, but the security group's ingress rule references the subnet's CIDR, creating a hidden circular dependency.
  vpc_id = aws_vpc.legacy_vpc.id

  # TODO: The app_name variable is used by the security group module inside to name resources, but also referenced in the subnet tags below.
  app_name = "legacy-pci-app-${var.environment}"

  # This is a workaround because the module outputs the subnet IDs, but we need them for the security group ingress rule.
  # The module's security group is defined with an ingress rule that uses: cidr_blocks = [module.legacy_application.subnet_cidr]
  # Meanwhile, the subnet inside the module has tags that reference: { "Application" = var.app_name }
  # And the security group's name is derived from var.app_name.
  # This creates an implicit cycle: subnet -> sg (via tags/name) -> subnet (via cidr_blocks).
  
  # Dummy secrets injected into environment variables (NOT in comments):
  app_env_vars = {
    AWS_ACCESS_KEY_ID     = "AKIAIOSFODNN7EXAMPLE"
    AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    STRIPE_API_KEY        = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
    DB_URI                = "postgresql://user:SuperSecretPass123!@pci-db.internal:5432/legacy_db"
  }

  # RSA key for legacy service account (multi-line secret):
  service_account_key = <<-EOT
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAz6M8Q7h6z7qJzKt8wXbN1lQ2sLpP5qYHjJvW8rRtGkLmY6q
... (truncated for brevity) ...
-----END RSA PRIVATE KEY-----
EOT
}

# Old, commented-out configuration from the previous deployment (kept for rollback):
# resource "aws_security_group" "old_sg" {
#   name        = "old-${var.environment}-sg"
#   description = "Deprecated security group"
#   vpc_id      = aws_vpc.legacy_vpc.id
# }

# The subnet is defined inside the module, but we are trying to reference its CIDR from outside for the security group rule.
# This is a red herring - the actual cycle is inside the module between its own subnet and security group resources.
# TODO: Fix this workaround after the module is refactored.

variable "environment" {
  default = "prod"
}

# This VPC is referenced by the module, but it's fine.
resource "aws_vpc" "legacy_vpc" {
  cidr_block = "10.0.0.0/16"
  tags = {
    Name = "legacy-${var.environment}-vpc"
  }
}

# The cycle is triggered when Terraform tries to resolve:
# 1. module.legacy_application.subnet_cidr (needs sg to compute tags?)
# 2. module.legacy_application.security_group_id (needs subnet_cidr for ingress rule)
# The module's internal resources have a hidden dependency loop.
AI Audit Console
> Waiting for pre-flight audit...
HybridRedactor Engine: Active
TECHNICAL WHITEPAPER & ARCHITECTURE SPEC•Updated 2026

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 implicit depends_on chains, or restructuring module outputs.
  • Fast path: Use our Client-Side Sandbox above to auto-refactor this — paste your failing .tf files and get a dependency-safe rewrite without sending your state or secrets anywhere.

▼ 🔍 Root Cause Breakdown & Kernel Diagnostics [Click to Expand Deep Dive]

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.


▼ 🔍 Detailed Impact & Blast Radius Analysis [Click to Expand Deep Dive]

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 resource Y, which passes an ARN back into the same module via a variable — the cycle is three hops deep and terraform graph is the only reliable way to visualize it.

Common triggers that engineers miss:

  1. depends_on on a module that itself outputs a value used by the depending resource.
  2. Security group circular references — aws_security_group_rule resources that reference each other's IDs across module boundaries.
  3. 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.
  4. Data sources misused as outputs — using a data block 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 locals to 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_rule resources at root scope rather than embedding rules inside the aws_security_group resource 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 infrastructure configs into public LLMs? Generic AI interfaces log your company's ARNs, connection strings, and private keys. StackEngine enforces client-side zero-trust boundaries—the sandbox above executes 100% locally in your browser. Drop your broken manifest above to redact secrets locally and synthesize deterministic patch drafts with your own API key.

(For CI/CD pipeline automation, install via npm i -g stackengine).


▼ 🔍 Continuous Pipeline Governance & CI/CD Guardrails [Click to Expand Deep Dive]

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.

Official Grounding & Normative Specifications

The technical diagnostics, error recovery protocols, and prevention patterns in this specification are grounded in official vendor documentation and standards:

Diagnostic & Architectural FAQ

Q1.How do I identify which resources are forming the cycle in Terraform?
The root cause is a directed cycle in your dependency graph, where resource A depends on B, and B depends on A, either directly or through a chain of references. You can manually trace it by running `terraform graph 2>&1 | grep -A 30 'Cycle'` to list the nodes, or visualize the loop with `terraform graph | dot -Tsvg > cycle.svg`. The nodes (e.g., `module.networking.aws_security_group.this`) show the exact conflicting addresses. This is a fundamental graph-theory problem in IaC that requires precise edge identification.
Q2.Does removing `depends_on` from a module always fix a dependency cycle?
Manually removing `depends_on` is a gamble—it might break one edge, but if the cycle is driven by implicit references (like a module output feeding back into its own input), the cycle persists. Manual edits risk introducing subtle misconfigurations or security gaps, as you might inadvertently expose resources or break zero-trust segmentation. Instead of guessing, drop the broken manifest into the StackEngine Local Sandbox above to automatically compile a zero-trust fix that surgically resolves the cycle without compromising security.
Q3.Can Terraform cycles occur inside a single module without crossing module boundaries?
Absolutely. Internal cycles, like two security groups referencing each other's IDs, are common and just as destructive. At scale, these cycles proliferate across mono-repos, creating compliance nightmares—imagine auditing hundreds of interdependent modules for cyclic violations across dev, staging, and prod. For enterprise-grade, automated PR drafting under Maker-Checker review, upgrade to the StackEngine CI/CD Automator (Team Edition). It integrates on-prem into your CI/CD pipeline to detect and fix cycles across entire codebases, ensuring continuous compliance and eliminating manual toil.