Terraform Cycle Error: Untangle Resource Dependencies
Read the cycle chain, find the convenience reference, and replace it with a data source.
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓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
- 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.
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.
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.
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.
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.
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.
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.
Two Security Groups, One Loop: How a Hardening Review Froze the Network Stack
- 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.
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.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.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.terraform validate — module cycles clear as soon as outputs flow one direction.terraform validate, then plan to confirm the secret value still reaches the consumer without a back-reference.| File | Command / Code | Purpose |
|---|---|---|
| see-the-cycle.sh | terraform validate | Reading the Cycle Message as a Map of Your Dependency Loop |
| break-the-loop.tf | resource "aws_security_group" "app" { | Cutting the Convenience Edge |
| depends-on-trap.tf | resource "aws_instance" "app" { | Why depends_on Usually Tightens the Knot Instead of Cutting |
| layered.tf | variable "vpc_id" { | Layering Your Architecture So Cycles Can't Form |
Key takeaways
Common mistakes to avoid
5 patternsTrying random edits until the cycle message goes away
Adding depends_on to fix ordering instead of adding the missing reference
Storing a secret in resource A that resource B needs, while B's identity lives in A
Wiring module outputs in a circle between two modules
Letting leaf resources feed values back into foundational ones
Interview Questions on This Topic
What does 'Error: Cycle' mean in Terraform?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's Terraform. Mark it forged?
5 min read · try the examples if you haven't