terraform · level 7

Lifecycle & Dependencies

Timing, replacement, count vs for_each, moved blocks.

125 XP

Lifecycle & Dependencies

Once you're past the basics, the trickiest Terraform questions are about timing: which order do resources change in, when does Terraform destroy-then-recreate vs update in place, and how do you stop an apply from doing the wrong thing.

Implicit dependencies

Most dependencies are implicit. When resource A's config references resource B's attribute, Terraform knows A must be created after B:

resource "aws_security_group" "web" { ... }

resource "aws_instance" "web" {
  vpc_security_group_ids = [aws_security_group.web.id]   # implicit dependency
}

The graph builds itself from references. You almost never need to declare dependencies by hand.

Explicit depends_on

When the dependency is real but Terraform can't see it (e.g., one resource creates a side effect another reads), use depends_on:

resource "aws_iam_role_policy_attachment" "lambda" {
  role       = aws_iam_role.lambda.name
  policy_arn = aws_iam_policy.lambda.arn
}

resource "aws_lambda_function" "main" {
  # ...
  depends_on = [aws_iam_role_policy_attachment.lambda]
}

Without depends_on, Terraform might create the Lambda before the policy attachment exists; AWS would then fail or the function would run with insufficient permissions on first invocation.

Use sparingly — implicit beats explicit.

count vs for_each

Two ways to create N copies of a resource.

count is integer-indexed:

resource "aws_instance" "web" {
  count         = 3
  ami           = "ami-..."
  instance_type = "t3.micro"
}

# Reference: aws_instance.web[0], aws_instance.web[1], ...

for_each is keyed:

resource "aws_instance" "web" {
  for_each      = toset(["us-east-1a", "us-east-1b"])
  ami           = "ami-..."
  instance_type = "t3.micro"
  availability_zone = each.key
}

# Reference: aws_instance.web["us-east-1a"], ...

Prefer for_each when the items have meaningful identities. If you remove the middle item from a count = 5 list, every later instance shifts by one index — Terraform sees that as "destroy the last one + recreate everything in between". With for_each, removing one key only destroys that one resource.

lifecycle block

Three useful options:

resource "aws_instance" "web" {
  ami = "ami-..."

  lifecycle {
    create_before_destroy = true     # spin up the new one first, then kill the old
    prevent_destroy       = true     # refuse to destroy this resource on apply
    ignore_changes        = [tags]   # don't recreate when this attribute drifts
  }
}
  • create_before_destroy — for resources that need zero downtime when replaced. The new instance is created before the old one is destroyed.
  • prevent_destroy — guard rail for production resources. If apply would destroy this, it errors out instead. Useful on databases, hosted zones, anything irreplaceable.
  • ignore_changes — when something else mutates an attribute (autoscaler, CI tooling) and Terraform should stop "fixing" it back. Specify which attributes; pass all only as a last resort.

In-place vs destroy-and-recreate

For each attribute you change, the provider decides whether the change is in-place updateable or requires replacing the resource. The plan output marks it:

~ resource "aws_instance" "web" {
    ~ tags = {
        Name = "web" -> "web-prod"   # in-place
    }
}

-/+ resource "aws_security_group" "web" {
    ~ name = "web" -> "web-prod"     # forces replacement
}

~ is in-place. -/+ is destroy then create. +/- is create then destroy (with create_before_destroy).

If a "forces replacement" change would destroy production data (a database, a storage bucket), STOP. Either change the resource without changing the attribute, or accept the rebuild and migrate data first.

moved blocks

When you rename or restructure a resource in code, Terraform sees it as "destroy old, create new". A moved block tells it "no, that's the same resource, just reflect the new address in state":

moved {
  from = aws_s3_bucket.logs
  to   = aws_s3_bucket.application_logs
}

After a successful plan that shows a no-op, the moved block can be deleted. It's a one-shot rename helper.

removed blocks (1.7+)

The mirror image: when you delete a resource block from your config and want Terraform to forget about it without destroying the cloud resource:

removed {
  from = aws_s3_bucket.logs
  lifecycle { destroy = false }
}

Equivalent to terraform state rm aws_s3_bucket.logs but encoded in the config so it's reproducible across teammates.