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.idinside theaws_security_group.thisblock 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 separatedatasource 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
.tfblock 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:
Full deployment freeze. Every
plan,apply, anddestroyin 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.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 orterraform state rmmay be required.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.
The most common self-reference traps:
- Security Group self-referencing rules: Defining an ingress rule that allows traffic from the same SG (
self = trueis valid, but referencingaws_security_group.this.idinside the same resource block is not). - IAM Role + inline policy cycle: An
aws_iam_rolereferencing anaws_iam_role_policyoutput, and that policy referencing the role's ARN via the role resource reference rather than a local. depends_oncreating accidental back-edges: Addingdepends_onto a resource that is already an implicit dependency creates a cycle.- Module output self-loops: A module output referencing a resource that
depends_onthe module itself.
- Security Group self-referencing rules: Defining an ingress rule that allows traffic from the same SG (
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.