Initializing Enclave...

How to Fix Terraform 'Error: cycle in dependency graph' Caused by Self-Reference

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins

TL;DR

  • What broke: A Terraform resource block contains an expression that references its own resource address (e.g., aws_security_group.this.id inside the aws_security_group.this block itself), creating a circular node in the DAG that Terraform's graph walker cannot resolve.
  • How to fix it: Extract the self-referencing value into a local, a separate data source lookup, or restructure the dependency chain so no resource depends on its own computed attributes during the plan phase.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your failing .tf block and get corrected HCL instantly without sending your state file or ARNs to any server.

The Incident (What Does the Error Mean?)

You ran terraform plan and hit a hard stop:

Error: cycle in dependency graph

Cycle: aws_security_group.app_sg (expand) -> aws_security_group_rule.allow_self -> aws_security_group.app_sg (expand)

Terraform detected a cycle in the dependency graph. This is always a bug.
Please report it at https://github.com/hashicorp/terraform/issues

Or a self-reference variant:

Error: cycle in dependency graph

Cycle: aws_iam_role.lambda_exec (expand) -> aws_iam_role_policy.inline -> aws_iam_role.lambda_exec (expand)

Immediate consequence: terraform plan exits with code 1. No diff is generated. No apply is possible. If this is in a CI/CD pipeline, the deployment is fully blocked. If the cycle was introduced mid-refactor on a live workspace, your infrastructure is frozen — you cannot add, change, or destroy any resource in the affected module until the graph is clean.

Terraform builds a Directed Acyclic Graph (DAG) of all resources and their dependencies before executing any operation. A self-reference or circular dependency makes the graph non-acyclic. The graph walker has no valid traversal order and throws a fatal error. This is not a warning. There is no partial plan.


The Attack Vector / Blast Radius

This is not a security misconfiguration in the traditional CVE sense, but the blast radius in a production IaC workflow is severe:

  1. Full deployment freeze. Every plan, apply, and destroy in the affected root module or workspace fails. If your module is shared (a child module called by multiple root modules), every caller is broken simultaneously.

  2. State drift accumulation. If the cycle was introduced after a partial apply, some resources may have been created before Terraform hit the cycle on a subsequent run. Those resources now exist in the real cloud environment but cannot be managed, modified, or destroyed via Terraform until the cycle is resolved. Manual intervention or terraform state rm may be required.

  3. CI/CD pipeline paralysis. In GitOps workflows (Atlantis, Terraform Cloud, GitHub Actions), a cycle error blocks all PRs touching that module. Teams waiting on downstream infrastructure — application deployments, DNS records, certificate provisioning — are stalled.

  4. The most common self-reference traps:

    • Security Group self-referencing rules: Defining an ingress rule that allows traffic from the same SG (self = true is valid, but referencing aws_security_group.this.id inside the same resource block is not).
    • IAM Role + inline policy cycle: An aws_iam_role referencing an aws_iam_role_policy output, and that policy referencing the role's ARN via the role resource reference rather than a local.
    • depends_on creating accidental back-edges: Adding depends_on to a resource that is already an implicit dependency creates a cycle.
    • Module output self-loops: A module output referencing a resource that depends_on the module itself.

How to Fix It (The Solution)

Root Cause Identification First

Run this to get the full cycle path printed to stdout before touching any code:

terraform graph | grep -A 5 "cycle"
# Or pipe to graphviz for a visual:
terraform graph | dot -Tsvg > graph.svg

The terraform graph output will show you the exact node names forming the cycle.


Scenario 1: Security Group Self-Reference (Most Common)

The Bad Pattern — resource referencing its own computed id:

- resource "aws_security_group" "app_sg" {
-   name        = "app-sg"
-   description = "Application security group"
-   vpc_id      = var.vpc_id
-
-   ingress {
-     from_port       = 8080
-     to_port         = 8080
-     protocol        = "tcp"
-     # CYCLE: referencing aws_security_group.app_sg.id inside aws_security_group.app_sg
-     security_groups = [aws_security_group.app_sg.id]
-   }
- }
+ resource "aws_security_group" "app_sg" {
+   name        = "app-sg"
+   description = "Application security group"
+   vpc_id      = var.vpc_id
+   # No ingress block here — self-referencing rules must be separate resources
+ }
+
+ resource "aws_security_group_rule" "allow_self_ingress" {
+   type              = "ingress"
+   from_port         = 8080
+   to_port           = 8080
+   protocol          = "tcp"
+   self              = true  # Terraform-native self-reference — no cycle
+   security_group_id = aws_security_group.app_sg.id
+ }

Key principle: Use self = true in aws_security_group_rule for intra-SG rules. Never reference the parent SG's computed id inside its own ingress/egress inline block.


Scenario 2: IAM Role + Policy Circular Dependency

- resource "aws_iam_role" "lambda_exec" {
-   name               = "lambda-exec-role"
-   assume_role_policy = data.aws_iam_policy_document.assume.json
-   # CYCLE: inline_policy block referencing aws_iam_role_policy.inline
-   inline_policy {
-     name   = aws_iam_role_policy.inline.name
-     policy = aws_iam_role_policy.inline.policy
-   }
- }
-
- resource "aws_iam_role_policy" "inline" {
-   role   = aws_iam_role.lambda_exec.id
-   policy = data.aws_iam_policy_document.lambda_perms.json
- }
+ # Use EITHER inline_policy inside the role OR a separate aws_iam_role_policy. Never both.
+ resource "aws_iam_role" "lambda_exec" {
+   name               = "lambda-exec-role"
+   assume_role_policy = data.aws_iam_policy_document.assume.json
+   # No inline_policy block — managed by separate resource below
+ }
+
+ resource "aws_iam_role_policy" "inline" {
+   name   = "lambda-exec-inline-policy"
+   role   = aws_iam_role.lambda_exec.id
+   policy = data.aws_iam_policy_document.lambda_perms.json
+ }

Scenario 3: depends_on Back-Edge Creating a Cycle

- resource "aws_s3_bucket" "artifacts" {
-   bucket = "my-artifacts-${var.env}"
-   # CYCLE: depends_on creates a back-edge to a resource that already
-   # implicitly depends on this bucket
-   depends_on = [aws_s3_bucket_policy.artifacts_policy]
- }
-
- resource "aws_s3_bucket_policy" "artifacts_policy" {
-   bucket = aws_s3_bucket.artifacts.id  # implicit dependency on artifacts
-   policy = data.aws_iam_policy_document.bucket_policy.json
- }
+ resource "aws_s3_bucket" "artifacts" {
+   bucket = "my-artifacts-${var.env}"
+   # Removed depends_on — the implicit dependency via aws_s3_bucket_policy.artifacts_policy.bucket
+   # already establishes the correct order: bucket -> policy
+ }
+
+ resource "aws_s3_bucket_policy" "artifacts_policy" {
+   bucket = aws_s3_bucket.artifacts.id
+   policy = data.aws_iam_policy_document.bucket_policy.json
+ }

Rule: Never add depends_on to resource A pointing to resource B if resource B already has an implicit reference to resource A. Terraform's implicit dependency tracking handles it. Explicit depends_on on top creates a back-edge.


Enterprise Best Practice: Use locals to Break Computed Attribute Chains

When a value needs to be shared across resources that would otherwise create a cycle, extract it into a local computed from a non-cyclic source:

- # Pattern that risks cycles when the computed value is reused
- resource "aws_lb" "app" {
-   name = "app-lb-${aws_lb_target_group.app.name}"
-   ...
- }
+ # Compute the shared name segment once, reference the local everywhere
+ locals {
+   app_name_slug = "app-${var.env}-${var.region}"
+ }
+
+ resource "aws_lb" "app" {
+   name = "lb-${local.app_name_slug}"
+   ...
+ }
+
+ resource "aws_lb_target_group" "app" {
+   name = "tg-${local.app_name_slug}"
+   ...
+ }

💡 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

Don't wait for terraform plan in a pipeline to catch this. Shift left.

1. terraform validate as a Pre-Commit Hook

# .git/hooks/pre-commit
#!/bin/bash
terraform init -backend=false
terraform validate
if [ $? -ne 0 ]; then
  echo "Terraform validation failed. Fix cycles before committing."
  exit 1
fi

terraform validate catches self-references and most cycle patterns without requiring cloud credentials or a backend.

2. tflint + terraform graph in GitHub Actions

# .github/workflows/tf-lint.yml
- name: Validate Terraform Graph (Cycle Detection)
  run: |
    terraform init -backend=false
    terraform graph > /tmp/tf_graph.dot
    # Fail if graphviz detects cycles (non-DAG structure)
    python3 -c "
    import subprocess, sys
    result = subprocess.run(['terraform', 'validate'], capture_output=True, text=True)
    if result.returncode != 0:
        print(result.stderr)
        sys.exit(1)
    "

3. Checkov Policy — Flag depends_on Overuse

# checkov custom check: flag explicit depends_on on resources
# that already have implicit references (common cycle source)
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck

class CheckExplicitDependsOn(BaseResourceCheck):
    def __init__(self):
        name = "Ensure depends_on is not redundantly set alongside implicit references"
        id = "CKV_CUSTOM_TF_001"
        super().__init__(name=name, id=id, supported_resources=['*'], block_type='resource')

    def scan_resource_conf(self, conf):
        # Flag any resource with both depends_on and attribute-level references
        has_depends_on = 'depends_on' in conf
        return not has_depends_on  # Trigger review on any depends_on usage

4. Atlantis pre_workflow_hooks — Block Cycle PRs

# atlantis.yaml
version: 3
projects:
  - dir: .
    workflow: safe-plan
workflows:
  safe-plan:
    plan:
      steps:
        - run: terraform init -backend=false
        - run: terraform validate  # Fails fast on cycles before plan
        - plan

Bottom line: terraform validate is free, fast, and catches cycles in under 2 seconds. There is no valid reason it should not be the first step in every CI pipeline touching Terraform code.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →