TerraformTerraform

Error: Cycle: resources depend on each other

Terraform's dependency graph must be acyclic. How to read the cycle it prints, visualise it, and break the three patterns that cause nearly all of them.

hard fix6 min read

the terraform error
Error: Cycle: aws_security_group.app, aws_security_group.db

Error: Cycle: module.vpc.aws_route_table.private, module.vpc.aws_nat_gateway.this, module.vpc.aws_subnet.public

Error: Cycle: aws_iam_role.lambda, aws_iam_role_policy.lambda, aws_lambda_function.api

Do this first3 steps

Run these in order. Each one tells you what its output means before you change anything.

  1. 1

    Read the cycle, it names every resource in the loop

    terraform plan -no-color 2>&1 | grep -A5 "Error: Cycle"

    The list is the loop in order. Two security groups referencing each other is the single most common instance, and the fix is to move the rules out of the group resources.

  2. 2

    Visualise the graph when the cycle is not obvious

    terraform graph -type=plan | grep -E "aws_security_group" | head -20

    terraform graph emits DOT. Piping it through dot -Tsvg gives a picture, but grepping for the resources named in the error is usually enough to see which attribute reference closes the loop.

  3. 3

    Break the loop by extracting the mutual reference into its own resource

    terraform validate

    Rules, attachments and associations exist as standalone resources precisely so that two objects can reference each other without the objects themselves forming a cycle.

All 7 sections

Terraform builds a directed acyclic graph of resources and walks it. A cycle means A must exist before B and B must exist before A, which cannot be satisfied, so the plan fails before anything runs.

The error lists every resource in the loop:

Error: Cycle: aws_security_group.app, aws_security_group.db

Pattern 1: Security groups referencing each other

By far the most common. The app group allows traffic to the database group, and the database group allows traffic from the app group:

resource "aws_security_group" "app" {
  name = "app"
  egress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.db.id]   # needs db
  }
}

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

Each group's definition depends on the other's id. Cycle.

The fix is to define the groups empty and attach the rules separately:

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

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

resource "aws_vpc_security_group_egress_rule" "app_to_db" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.db.id
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
}

Now both groups are created first with no dependencies, then both rules. No cycle.

Note the newer aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule replace the older aws_security_group_rule, and are worth preferring: one rule per resource makes plans readable and avoids the perpetual-diff problems the older resource had.

Do not mix inline blocks and rule resources on the same group. Terraform fights itself: the inline blocks describe the complete rule set, so it removes rules added by the separate resources on every apply, and the next apply adds them back. If you use rule resources, the group must have no inline ingress or egress blocks at all.

Pattern 2: An unnecessary depends_on

resource "aws_iam_role" "lambda" {
  name               = "lambda-exec"
  assume_role_policy = data.aws_iam_policy_document.assume.json
  depends_on         = [aws_lambda_function.api]     # wrong direction
}

resource "aws_lambda_function" "api" {
  role = aws_iam_role.lambda.arn                      # genuinely needs the role
}

The function legitimately depends on the role through role = .... The added depends_on points the other way and closes the loop.

Terraform infers dependencies from expression references automatically. An explicit depends_on is only needed for dependencies it cannot see, such as an IAM policy that must exist before an API call the provider makes implicitly. Adding it "to be safe" is how most of these cycles get created.

Remove it and let the reference do the work.

Pattern 3: Modules depending on each other

module "network" {
  source     = "./modules/network"
  cluster_id = module.cluster.id
}

module "cluster" {
  source    = "./modules/cluster"
  subnet_ids = module.network.subnet_ids
}

Terraform treats a module as a unit in the graph, so this is a cycle even if no individual resource pair is circular.

Usually the modules are drawn at the wrong boundary. Move the one resource that creates the back-reference out into the root, or into a third module that depends on both:

module "network" { source = "./modules/network" }

module "cluster" {
  source     = "./modules/cluster"
  subnet_ids = module.network.subnet_ids
}

resource "aws_ec2_tag" "network_cluster" {
  resource_id = module.network.vpc_id
  key         = "cluster"
  value       = module.cluster.id
}

Dependencies flow one way and the cycle is gone.

Seeing the graph

terraform graph -type=plan > graph.dot
dot -Tsvg graph.dot -o graph.svg

Needs Graphviz. For a large configuration the picture is unreadable, so narrow it:

terraform graph -type=plan | grep -E "aws_security_group|aws_instance"

In practice the error message names the whole loop, and reading the resources it lists for a mutual reference is faster than rendering anything.

Cycles that only appear on destroy

terraform plan -destroy

Destroy reverses the graph, and a configuration that applies cleanly can have a cycle in reverse. create_before_destroy is the usual trigger, because it changes the ordering constraints:

lifecycle {
  create_before_destroy = true
}

When one resource in a dependency chain has it and another does not, the ordering can become unsatisfiable. The rule is that create_before_destroy has to propagate: if a resource has it, everything it depends on generally needs it too. Terraform's documentation is explicit that mixing them within a dependency chain causes exactly this.

A checklist

  1. Read the resource list in the error. That is the loop.
  2. Two security groups → move the rules into separate rule resources.
  3. Never mix inline ingress/egress blocks with rule resources on one group.
  4. Look for a depends_on pointing the opposite way to a real reference, and delete it.
  5. Modules referencing each other → move the back-reference to the root.
  6. terraform graph -type=plan | grep <resource> when the loop is not obvious.
  7. Cycle only on destroy → look at create_before_destroy in that chain.
  8. Apply create_before_destroy consistently across a dependency chain, not to one member.

Frequently Asked Questions

What causes a cycle error in Terraform?

Two or more resources that each need the other to exist first. Terraform builds a directed acyclic graph from the references in your expressions plus any explicit depends_on, and walks it in dependency order. A cycle makes that order impossible to compute, so the plan fails before anything is created. The error lists every resource in the loop, and the cause is almost always either a mutual attribute reference or a depends_on pointing against an existing reference.

How do I fix two security groups that reference each other?

Define both groups with no inline rules, then create the rules as separate aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources. The groups are then created first with no dependencies on each other, and the rules afterwards, which breaks the loop. The important caveat is not to mix the two styles: a group with inline ingress or egress blocks treats those as the authoritative complete set, so it will remove rules created by separate resources on every apply.

Should I use depends_on to be safe?

No. Terraform infers dependencies from every reference in your expressions, so role = aws_iam_role.lambda.arn already creates the ordering. Adding depends_on on top of that is redundant at best, and when pointed the wrong way it creates exactly the cycle you are trying to avoid. Reserve it for dependencies Terraform genuinely cannot see, such as an IAM policy that must exist before a provider makes an implicit API call, and remove any you cannot justify.

Why does a cycle only appear during terraform destroy?

Because destroy walks the graph in reverse, and the reversed ordering constraints are not always satisfiable even when the forward ones are. The usual trigger is create_before_destroy, which changes how Terraform orders replacement. When one resource in a dependency chain has it set and the resources it depends on do not, the combined constraints can become impossible. The rule is to propagate it: if a resource uses create_before_destroy, everything it depends on generally needs it too.

How do I visualise the dependency graph?

terraform graph -type=plan writes the graph in Graphviz DOT format, which dot -Tsvg graph.dot -o graph.svg renders. For anything beyond a small configuration the full picture is too dense to read, so filter it first by piping through grep for the resource types named in the error. In practice the error message already lists the whole cycle, so scanning those specific resources for a mutual reference is usually faster than rendering a diagram.

Reference and practice

Learn the underlying concept

Other Terraform errors