Home › Cloud › Terraform Cycle Error: Untangle Resource Dependencies
Intermediate 5 min · September 23, 2026

Terraform Cycle Error: Untangle Resource Dependencies

Read the cycle chain, find the convenience reference, and replace it with a data source.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 13 min
  • ✓You've written multi-resource Terraform configs with references between them
  • ✓Comfort reading plan output and resource attribute references
  • ✓A scratch config where breaking things temporarily is acceptable
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Error: Cycle means your references form a loop with no valid build order — Terraform rejects the config before touching anything.
  • Read the message as a map: it names the loop members in order, so open those blocks first.
  • Break the loop by replacing the convenience reference with a data source, variable, or restructured output.
  • Don't add depends_on — it adds ordering edges without data flow and usually tightens the knot.
✦ Definition~90s read
What is Terraform Cycle Error in Resource Dependencies?

Terraform's dependency graph is the directed structure it derives from your configuration before building anything: resources and modules are nodes, and attribute references are edges that dictate build order. When resource A reads aws_db_instance.main.endpoint, Terraform records an edge requiring the database before the app — no timestamps, no scripts, just relationships.

★
Picture two friends assembling furniture: Ana won't start until Ben hands her a screw, and Ben won't start until Ana hands him the screwdriver she's holding.

The planner topologically sorts this graph to decide what to create first, what can run in parallel, and what must wait.

A cycle is a loop in that graph — A waits on B waits on A — for which no valid ordering exists. Terraform detects it during validation, before any API call, and aborts the entire run: there is no partial apply of a cyclic config, because any order would violate some edge.

The error lists the loop members so you can trace the circuit. Note what a cycle is not: it's not a timing problem, not eventual consistency, and not fixable by retries, parallelism flags, or targeting. It's a logical contradiction in your description of relationships.

depends_on fits here as a manual edge: it orders two nodes without any data flowing between them, covering hidden dependencies like provisioner side effects. Because it's hand-drawn rather than inferred, it can contradict the inferred edges — demanding A-first where references demand B-first — and that contradiction is a cycle no data change can resolve.

Grasp the graph model and every cycle becomes a traceable, fixable puzzle instead of a cryptic rejection.

Plain-English First

Picture two friends assembling furniture: Ana won't start until Ben hands her a screw, and Ben won't start until Ana hands him the screwdriver she's holding. Both wait forever — that's a dependency cycle. The fix isn't shouting 'just start'; it's removing one wait. Maybe Ana grabs a spare screwdriver from the toolbox (a data source) instead of waiting on Ben. One wait disappears, the order becomes obvious, and the shelf gets built. Terraform cycles work exactly the same way.

Few Terraform errors feel as personal as Error: Cycle. You wrote what looks like perfectly reasonable configuration — the app needs the database URL, the database security group needs the app's security group — and Terraform responds by naming your resources in a loop and refusing to do anything at all. No partial apply, no helpful suggestion, just a circle.

The frustration comes from a mental model mismatch. You think in terms of time (start the database, then the app), but Terraform thinks in terms of a graph: nodes are resources, edges are references, and a cycle means no valid build order exists. Your config doesn't describe steps — it describes relationships, and you've described an impossible one.

The good news: every cycle breaks the same way. One of the edges in the loop is load-bearing and the other is convenience — a display value, a tag, an output threaded somewhere it doesn't strictly need to go. This article teaches you to read the cycle message as a map, find the convenience edge, and replace it with a data source, a variable, or a restructured output. You'll also learn why depends_on usually tightens the knot instead of cutting it.

Reading the Cycle Message as a Map of Your Dependency Loop

Terraform builds a directed graph from your configuration long before it calls any cloud API: every resource is a node, and every attribute reference is an edge meaning 'build the target first.' The cycle error fires during validation when that graph contains a loop — A waits on B waits on A — because no ordering satisfies all edges at once. Nothing is broken in the cloud; your description of relationships is self-contradictory, and Terraform refuses to guess which edge you meant less.

The message itself is a map, not just an error. Error: Cycle followed by a comma-separated list names the loop members in dependency order — read them as A depends on B depends on A, and you've got the exact circuit to open. Beginners treat the list as suspects and edit around them; experienced engineers treat it as directions and open precisely those blocks.

Internalize this and the panic fades: a cycle is a logic puzzle with a guaranteed solution, because every real infrastructure has a valid build order. Your config just doesn't describe it yet. One of the edges represents a genuine physical dependency (the app truly needs the database's address), and another represents convenience (the database's tags mention the app's name). Find the convenience edge, and you've found your cut.

see-the-cycle.shBASH
1
2
3
4
5
# What Terraform prints: the loop members, in loop order
# Error: Cycle: aws_security_group.app, aws_security_group.db

# Confirm the loop: each block references the other
terraform validate
📊 Production Insight
In the security-group incident, the cycle named exactly two resources — and the team still spent hours editing unrelated files before someone read the message as directions.
🎯 Key Takeaway
The cycle list names loop members in order — open those blocks and classify each edge as load-bearing or convenience.

Cutting the Convenience Edge: Variables, Data Sources, and Restructured Outputs

Opening a loop means deleting exactly one edge, and the art is picking the right one. For each reference in the cycle, ask: does this resource physically need the other's computed value, or could the value arrive another way? The app genuinely needs the database's hostname — that's load-bearing. The database's ingress rule referencing the app's group ID, when a CIDR variable would do, is convenience wearing a security costume.

The replacement toolkit has three tools. Variables push values in from outside the graph — no edge at all. Data sources read existing infrastructure with a one-way read edge instead of a mutual management edge. Output restructuring moves the combination point up to a parent module so siblings stop referencing each other directly. Match the tool to the edge: static values become variables, live infrastructure becomes data reads, sibling chatter becomes parent-level wiring.

Verify surgically: change one edge, run validate, repeat. Validation is fast and credential-free, so there's no reason to batch guesses. When validate passes, run plan and confirm the infrastructure diff contains only what you intended — a pure graph refactor should show zero changes, which is the strongest proof your cut was clean.

break-the-loop.tfHCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# BEFORE: the loop — each group references the other
resource "aws_security_group" "app" {
  ingress {
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    security_groups = [aws_security_group.db.id]
  }
}

resource "aws_security_group" "db" {
  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }
}

# AFTER: break one edge — db takes a variable instead
variable "app_allowed_cidrs" {
  type = list(string)
}
📊 Production Insight
The team's fix changed zero infrastructure — the plan was empty. That empty plan was the proof the cut was pure graph surgery with no behavioral side effects.
🎯 Key Takeaway
Classify each loop edge, replace the convenience one with a variable, data source, or parent-level wiring, and validate after each cut.

Why depends_on Usually Tightens the Knot Instead of Cutting It

depends_on is the most misunderstood keyword in Terraform, and cycles are where the misunderstanding bills you. It adds an ordering edge with no data flow: 'build that first, though I need nothing from it.' That's occasionally necessary for hidden dependencies — a provisioner side effect, an eventual-consistency delay — but most depends_on blocks in the wild paper over a missing reference instead.

Here's how that manufactures cycles. Suppose resource A already references B (edge B-first), and someone adds depends_on pointing A-first to 'speed things up' or 'be safe.' The graph now demands both orders at once — a two-edge loop that no reference analysis would ever create. The cycle message names A and B, the engineer adds more depends_on to 'fix' it, and the knot tightens with every well-meant edit.

The rule is absolute: if two resources exchange data, express it with references and let Terraform infer order. Reserve depends_on for ordering needs invisible to the graph, and every time you write one, add a comment explaining what hidden dependency it covers. A depends_on without a comment is a future cycle with the fuse already lit — the next engineer can't tell whether it's load-bearing or leftover.

depends-on-trap.tfHCL
1
2
3
4
5
6
7
8
9
10
# The 'fix' that backfires: an edge with no data behind it
resource "aws_instance" "app" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  depends_on = [aws_db_instance.main]
}

# Prefer the reference — it carries data AND ordering:
# db_endpoint = aws_db_instance.main.endpoint
📊 Production Insight
One codebase audit found eleven depends_on blocks; nine papered over references that should have existed. Removing them cleared two latent cycles nobody had hit yet.
🎯 Key Takeaway
depends_on adds order without data — use it only for hidden dependencies with an explanatory comment, never for ordinary ordering.

Module-Level Cycles: When Outputs Flow in Both Directions

Module cycles are resource cycles wearing a trench coat: module A exports an output module B consumes, while B exports an output A consumes. The fix operates one level up — restructure so values flow a single direction through the calling module. Both children export their raw values, the parent combines them, and the parent passes results down as variables. Siblings talk through the parent, never directly.

This pattern forces a useful design question: if two modules genuinely need each other's managed values, are they actually two modules? Often the honest answer is no — they're one deployment unit split for aesthetics, fused by mutual need. Merging them converts cross-module outputs into plain internal references, and the 'cycle' evaporates because it was really just intra-component wiring all along.

Prevention is a review habit: every cross-module reference must state its direction, and reviewers check that no pair of modules references each other. One-way output contracts — module X may consume from Y, never both — are easy to verify in pull requests and eliminate module cycles as a class. The few minutes this costs in review saves the afternoons that module loops otherwise consume.

📊 Production Insight
A platform team merged two mutually-dependent modules after their third cycle incident and deleted 200 lines of output plumbing that had existed only to sustain the split.
🎯 Key Takeaway
Route sibling values through the parent module one way — or admit the modules are one unit and merge them.

Data Sources Done Right: One-Way Reads That Actually Open Loops

Data sources break cycles so often they've gained a mythical reputation — as if swapping any reference for a data read dissolves loops by magic. The reality is mechanical: a data source is a one-way read edge, and it only opens a loop when it replaces one direction of a two-way relationship. Understanding the mechanism keeps you from rebuilding the same loop with reads instead of references.

The classic working case: the app needs the database's address, and the database config mentions the app. Replace the database's mention with a variable or drop it, and let the app read the address via a data source (or keep its direct reference — only one direction needs to change). The loop opens because management edges now point one way while reads point the other.

The failure case matters just as much. A data source that reads a resource managed in the same configuration creates a genuine edge from the reader to the read — Terraform must build the target before reading it. If that target's own dependencies route back to the reader through any path, congratulations: same cycle, new syntax. Always re-validate after the swap, and if the cycle persists, sketch the full path — the loop survived somewhere you haven't looked yet.

⚠ Data Sources Aren't Magic
A data source that reads a resource managed in the same config still creates a dependency edge — if its dependencies loop back to its reader, you've rebuilt the cycle with a read. Data sources open loops; they don't grant immunity from them.
📊 Production Insight
An engineer 'fixed' a cycle by converting both references to data sources and got the same error back. The loop was structural, not syntactic — only restructuring the outputs cleared it.
🎯 Key Takeaway
A data source replaces one loop direction with a read edge — then re-validate, because a read that routes back still cycles.

Layering Your Architecture So Cycles Can't Form

The permanent defense against cycles is layering: arrange your architecture so values flow strictly downward — network and IAM at the base, data stores in the middle, applications at the top — and forbid upward references as a design law. A security group in the data layer takes CIDR variables; it never reaches up to read an app resource's attributes. An app reads database endpoints downward all day long without risk, because downward edges can't loop.

Enforcing this takes one review rule with teeth: any reference from a lower layer to an upper layer gets rejected, no matter how convenient. The engineer who wants it must instead push the value down as a variable from the root or expose it through a data source owned by the lower layer. This feels bureaucratic the first time and obvious the third — and it eliminates entire cycle categories (security-group loops, subnet/NAT loops, IAM-role loops) before they're written.

Layering also simplifies every other Terraform workflow: targeted plans become predictable, state moves follow layer boundaries, and new engineers learn the architecture by reading the direction of references. The cycle error, in this light, is really a layering violation detector — and a codebase that treats it that way stops generating cycles faster than any amount of post-hoc graph surgery.

layered.tfHCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Layers flow DOWN: network -> data -> app. Never back up.
variable "vpc_id" {
  type = string
}

resource "aws_security_group" "db" {
  vpc_id = var.vpc_id
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = var.app_allowed_cidrs
  }
}

data "aws_db_instance" "main" {
  db_instance_identifier = "prod-main"
}

output "db_endpoint" {
  value = data.aws_db_instance.main.endpoint
}
📊 Production Insight
After adopting the no-upward-references rule, one organization went a full year without a single cycle error across forty stacks — down from roughly one a month.
🎯 Key Takeaway
Values flow down through layers; upward references get rejected in review — direction discipline is the permanent cure.
● Production incidentPOST-MORTEMseverity: high

Two Security Groups, One Loop: How a Hardening Review Froze the Network Stack

Symptom
Monday's pipeline failed with Error: Cycle: aws_security_group.app, aws_security_group.db. No infrastructure was broken — nothing could even plan. The security groups had applied fine for months; the only change was a Friday review commit adding a direct group-to-group reference 'for tighter rules.'
Assumption
The security group rule was added during a security review as a 'quick hardening' change: reference the app tier directly so the database only accepts its traffic. The reviewer assumed references were free — that Terraform would simply build things in a sensible order. Nobody sketched the graph, because two references in two files didn't look like a loop until validation drew it.
Root cause
The database security group referenced the app security group's ID for ingress while the app security group referenced the database group's ID for egress. Each reference is an ordering edge, and together they formed a loop with no valid build order. Terraform's validator rejected the whole configuration, blocking every plan and apply on the stack — not just the security groups.
Fix
The team cut the convenience edge: the database security group kept a variable for allowed CIDR blocks instead of a reference to the app group, and the app group kept its reference to the database group for its egress rule. One direction of data flow, no loop. They validated, planned (no infrastructure diff — pure graph refactor), and applied cleanly. Then they added a review checklist item: every new cross-resource reference must state its direction, and foundation-layer resources may not reference workload-layer ones.
Key lesson
  • Security hardening that adds references can redraw the dependency graph — review graph direction, not just firewall semantics, when touching security groups.
  • A plan with zero infrastructure changes can still be a critical fix: graph-only refactors are invisible in diffs but unblock the entire pipeline.
  • Direction discipline (foundation never references workloads) prevents whole classes of cycles before anyone writes the second edge.
Production debug guideFive Terraform dependency cycle patterns, with the exact steps that break each loop.5 entries
Symptom · 01
Error: Cycle names two resources that reference each other
→
Fix
Draw the loop from the message: for Cycle: aws_app.a, aws_db.b, open both blocks and find the reference each makes to the other. Decide which direction is load-bearing (usually the consumer reading the provider's attribute) and which is convenience (tags, display outputs, back-references). Cut the convenience edge with a data source or variable.
Symptom · 02
Cycle appeared right after adding a depends_on block
→
Fix
Delete the depends_on and rerun terraform validate. If the cycle clears, the edge was artificial — leave it out and let the references order the graph. If ordering genuinely breaks, find the missing data reference and add that instead of restoring the hammer.
Symptom · 03
Cycle persists after your first fix attempt
→
Fix
Run terraform validate after each single change — it's fast and needs no credentials. Change one edge at a time: replace, validate, repeat. When validate passes, run terraform plan to confirm the graph change didn't alter the infrastructure diff beyond the intended refactor.
Symptom · 04
Error: Cycle names two modules instead of resources
→
Fix
Open both module blocks and list every output flowing each way. Move the combination logic up to the root module: each child exports values, the root passes them down as variables. Rerun terraform validate — module cycles clear as soon as outputs flow one direction.
Symptom · 05
Cycle involves a secrets resource and its consumer referencing each other
→
Fix
Restructure so the secret flows one way: the vault resource takes only variables, and the consumer reads the value through a data source or a passed variable. Validate with terraform validate, then plan to confirm the secret value still reaches the consumer without a back-reference.
Terraform Cycle Errors — Root Cause Comparison
Root CauseHow to ConfirmFixPrevention
Two resources reference each other directlyCycle message names both; each block contains a reference to the otherReplace one direction with a data source or variableEnforce layered design: foundation never references workloads
depends_on created an edge references didn't needRemoving the depends_on clears the cycle with no other changeDelete it and let implicit references order the graphReserve depends_on for hidden side effects only, with a comment
Two modules trade outputs in a circleCycle names module.a and module.b; outputs flow both waysRestructure so outputs flow one way through the root moduleOne-way output contracts between modules, reviewed in PRs
A secret and its consumer reference each otherVault entry needs the app ID while app config needs the secretData-source read breaks the loop; secret value flows one waySecrets flow down from vault modules; identities never flow up
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
see-the-cycle.shterraform validateReading the Cycle Message as a Map of Your Dependency Loop
break-the-loop.tfresource "aws_security_group" "app" {Cutting the Convenience Edge
depends-on-trap.tfresource "aws_instance" "app" {Why depends_on Usually Tightens the Knot Instead of Cutting
layered.tfvariable "vpc_id" {Layering Your Architecture So Cycles Can't Form

Key takeaways

1
A cycle means no valid build order exists
one edge in the loop must go, and it's usually the convenience reference.
2
Read the cycle message as a map
it names the loop members in order, not random resources.
3
Replace one direction with a data source, variable, or output restructure instead of reordering code.
4
depends_on adds edges without data flow
it creates cycles more often than it fixes them.
5
Keep module outputs flowing one way through the root; circular outputs mean the modules should merge.
6
Layer your design so foundation never references workloads
direction discipline prevents whole cycle classes.

Common mistakes to avoid

5 patterns
×

Trying random edits until the cycle message goes away

Symptom
The cycle hops between resource pairs with every change, and the config gets worse each round — outputs deleted that something needed, depends_on added that tightened the knot.
Fix
Read the cycle chain as a directed loop and find the edge that represents 'convenience' rather than necessity — usually an output threaded back for display. Cut that edge with a data source or a local, and the loop opens.
×

Adding depends_on to fix ordering instead of adding the missing reference

Symptom
The graph gains edges that don't correspond to data flow, cycles appear that reference-counting would never create, and removing any single depends_on changes behavior unpredictably.
Fix
Delete the depends_on and let references imply ordering. If plan then fails on ordering, the missing reference is the real bug — add the reference, not the hammer.
×

Storing a secret in resource A that resource B needs, while B's identity lives in A

Symptom
The vault entry needs the app's ID while the app config needs the secret value — a textbook A-to-B-to-A loop that no ordering flag can resolve.
Fix
Split the object: let the vault own the secret value and the app own a data-source read of it. Two resources with a data edge in one direction replace two resources with config edges in both directions.
×

Wiring module outputs in a circle between two modules

Symptom
Error: Cyclenames both modules, and neither can be targeted or moved independently — they're fused into one deployment unit wearing a two-module costume.
Fix
Keep outputs flowing one way: child modules expose values, the root (or a third module) combines them. A module that both exports to and imports from the same sibling is a cycle waiting for its second edge.
×

Letting leaf resources feed values back into foundational ones

Symptom
Security groups reference instance IPs while instances reference the security group, or subnets reference NAT gateways that reference the subnets — the base layer can't build because it waits on its own roof.
Fix
Draw the intended creation order on paper first: foundation (network, IAM) flows into workloads, never back. If a foundation resource needs a workload value, that value belongs in a variable or a data source, not a reference.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does 'Error: Cycle' mean in Terraform?
Q02JUNIOR
How does Terraform order resources, and when is depends_on appropriate?
Q03SENIOR
Why does replacing a reference with a data source break a cycle?
Q04SENIOR
Two modules pass outputs to each other in both directions. How do you fi...
Q05SENIOR
Design a secret-wiring pattern for app and vault resources that can't cy...
Q01 of 05JUNIOR

What does 'Error: Cycle' mean in Terraform?

ANSWER
It means the dependency graph has a loop: A needs B's output and B needs A's output, so neither can build first. Terraform detects this during validation before touching any infrastructure. The fix is removing one direction of the dependency, usually by replacing a reference with a data source, variable, or restructured output.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can I work around a cycle with -target?
02
What's the difference between a reference edge and a depends_on edge?
03
Can data sources create cycles too?
04
My config has three overlapping cycles. Where do I start?
05
How do module-level cycles differ from resource-level ones?
06
Can terraform graph help me see the cycle?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Terraform. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Terraform Resource Already Exists — Import, Don't Recreate
3 / 5 · Terraform
Next
Terraform Provider Configuration Not Present After Refactor
→