skip to content

A terraform apply fails with an error beginning "Error: Cycle:" that names two aws_security_group resources. What has gone wrong, and how do you break it?

level: seniorimportance: should knowfreq 50%

answer

  1. graph must be acyclic to be ordered
  2. each side references the other's id
  3. inline rules live inside the group
  4. extract the edge into a third node
  5. graph -draw-cycles highlights the loop

basics

~20 s

Two objects reference each other, so Terraform's dependency graph is no longer acyclic and no valid order exists. Break the loop by moving one direction of the relationship into a separate resource that both groups depend on, rather than referencing each other inline.

solid answer

~50 s

Terraform's graph has to be a DAG — a cycle means there is no order in which both resources can be created. With security groups it almost always comes from inline rules: group A's `ingress` block references `aws_security_group.b.id` while group B's references `aws_security_group.a.id`, so each must exist first. The fix is to take the relationship out of the group bodies. Declare both `aws_security_group` resources with no inline rules and express the rules as standalone `aws_security_group_rule` resources: each rule depends on both groups, but neither group depends on a rule, so the loop disappears. The same pattern breaks cycles generally — extract the mutual reference into a third node downstream of both. The other frequent cause is a hand-written `depends_on` pointing back up its own chain. `terraform graph -draw-cycles` highlights the loop when the error message alone is not enough.

code

hcl · 27 lines
hcl
variable "vpc_id" {
  type = string
}

resource "aws_security_group" "app" {
  name   = "app"
  vpc_id = var.vpc_id

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

resource "aws_security_group" "db" {
  name   = "db"
  vpc_id = var.vpc_id

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

go deeper

for a junior

Recognise that "Error: Cycle" means two things point at each other and Terraform cannot decide which comes first. Read the resource names in the error message and go look at those two blocks.

for a middle

Explain that the graph must be acyclic and name the usual culprit: inline security-group rules that reference each other. Show the fix of moving rules into standalone aws_security_group_rule resources so both edges point away from the groups.

for a senior

Diagnose the less obvious sources — a depends_on added to fix an earlier ordering bug, mutual module outputs, replacement ordering — and use terraform graph -draw-cycles when the message is not enough. Be firm that applying twice is not a fix.

for a principal

Frame it as a modelling problem: a cycle says two objects were drawn with a relationship that belongs to neither. Set the standard that every configuration must be creatable from an empty account, and treat any manual apply sequence as a defect in the design rather than a quirk of the tool.

## Why a cycle is fatal rather than merely awkward Terraform plans and applies by topologically sorting a directed acyclic graph. A topological sort exists only if the graph has no cycles: if A must come after B and B must come after A, there is no first node, so there is nothing Terraform can legally do. It refuses up front rather than picking arbitrarily, and the error names every node in the loop: ``` Error: Cycle: aws_security_group.app, aws_security_group.db ``` That member list is the most useful part of the message — the loop is exactly those nodes, and the edge you need to remove is between two of them. ## The security-group case, which is most of them in practice The AWS provider lets you write rules two ways: as `ingress` / `egress` blocks inside `aws_security_group`, or as separate `aws_security_group_rule` resources. Inline blocks are part of the group's own body, so a rule that allows traffic *from* another group makes the whole group depend on that other group. Two services that talk to each other in both directions therefore produce a mutual dependency: ```hcl resource "aws_security_group" "app" { ingress { security_groups = [aws_security_group.db.id] # app -> db } } resource "aws_security_group" "db" { ingress { security_groups = [aws_security_group.app.id] # db -> app => Cycle } } ``` The standalone-rule form breaks it because a rule is a third node: `aws_security_group_rule.db_from_app` references both groups, so both edges point *downward* from the groups to the rules and nothing points back. Terraform creates both groups concurrently, then both rules. This is the general shape of every cycle fix — find the piece of the relationship that does not have to live inside either object, and give it its own node. ## The other common causes **A hand-written `depends_on` that points back up the chain.** Someone adds `depends_on = [aws_instance.app]` to a resource the instance already references. The implicit edge goes one way, the explicit edge goes the other, and the graph closes. When a cycle appears immediately after someone "fixed an ordering bug", look at the `depends_on` they added first. **A module-level ordering loop.** Two modules that each consume an output of the other cannot both go first; the fix is the same as with resources — hoist the shared value out into a resource or variable both can read, so the estate becomes a fan-out instead of a ring. **Replacement ordering.** `create_before_destroy` inverts a node's create/destroy ordering, and Terraform requires that inversion to be consistent across everything the resource depends on; a partially applied inversion can present as a cycle. If the loop appears only when a resource is being replaced and not on a fresh create, this is the direction to look. ## Diagnosing when the error is not obvious With two named nodes, the error is usually enough — open both and find the mutual reference. With a long member list, or with generated configuration where the reference is buried in a `for` expression, render the graph: ``` terraform graph -draw-cycles | dot -Tsvg > graph.svg ``` `-draw-cycles` highlights the offending edges, which is faster than reading raw DOT. Note that Terraform's graph output is verbose — a real root module produces a picture too dense to read end to end — so use it to confirm a suspicion rather than to browse. ## What not to do The reflex fix that never works is adding *more* `depends_on` to "force" an order. A cycle is an over-constrained graph; adding edges tightens it further. The only fixes are to remove an edge (drop the reference, hardcode nothing, extract the value) or to split a node into two so the edges no longer close. Equally, do not solve it by applying twice — commenting out one side, applying, then uncommenting. It works once, on your machine, and leaves a configuration that cannot be created from scratch in a fresh account. That is exactly the property IaC exists to guarantee, and a reviewer should reject it.

  • Someone proposes fixing the cycle by adding depends_on between the two groups. Why will that not help?
    Because a cycle means the graph already has too many constraints, not too few. `depends_on` only adds edges, so it can turn a two-node loop into a three-node loop but can never open one. The fix is always to remove an edge or to split a node so the edges stop closing back on themselves.
  • Why is 'comment out one side, apply, then uncomment and apply again' an unacceptable fix?
    It makes the configuration un-appliable from scratch. The current environment survives only because of a manual sequence nobody recorded, so a fresh account, a rebuilt environment or a disaster-recovery run fails at the same cycle. Reproducibility is the whole point of keeping the infrastructure in code, and this quietly gives it up.
  • How would you spot a cycle that involves ten nodes rather than two?
    Read the member list first — the loop is exactly those nodes, so the edge you need is between two of them. If the mutual reference is buried in generated expressions, render the graph with `terraform graph -draw-cycles` piped into Graphviz, which colours the offending edges instead of making you read the whole DOT output.

saying these in an interview costs you the question

  • Add a depends_on to force one to go first
  • Just apply twice, it settles on the second run
  • Terraform picks an arbitrary order when it sees a cycle
  • Cycles mean a provider bug, file an issue
  • Comment one block out and re-add it after apply

context