Terraform Meta-Arguments: count, for_each, and Lifecycle
Stop copy-pasting resource blocks. count vs for_each and the reindexing trap, depends_on, lifecycle (create_before_destroy, prevent_destroy, ignore_changes), and dynamic blocks.
The first Terraform config everyone writes is a straight list of resource blocks: one bucket, one instance, one security group rule. It works, and then it stops scaling the moment you need three of something, or ten, or one per environment. The reflex is to copy-paste the block and tweak a name, which quietly becomes the worst thing in your codebase - a wall of near-identical HCL where a change has to be made in twelve places and one of them gets missed. Terraform has meta-arguments that live on any resource to handle exactly this: count and for_each to stamp out many copies, depends_on to force ordering, lifecycle to control how replacements happen, and dynamic blocks for repeatable nested config. This guide covers when to use each, and the reindexing trap that catches almost everyone once.
count: the index-based loop
count is the simpler of the two loops. Set count = N on a resource and Terraform creates N copies, each identified by a numeric index starting at zero. Inside the block you reference count.index to make each copy slightly different.
resource "aws_instance" "worker" {
count = 3
ami = "ami-0abcd1234"
instance_type = "t3.small"
tags = {
Name = "worker-${count.index}"
}
}
That gives you worker-0, worker-1, and worker-2. You address them as a list: aws_instance.worker[0], aws_instance.worker[1], and so on, and aws_instance.worker (no index) is the whole list, which is handy for wiring outputs.
count is also the idiomatic way to make a resource conditional. There is no if on a resource, so you set the count to 1 or 0:
resource "aws_eip" "nat" {
count = var.enable_nat ? 1 : 0
domain = "vpc"
}
When the flag is off you get zero copies and the resource simply does not exist.
The reindexing trap
Here is the gotcha that bites everyone eventually. count tracks resources by their position in the list, not by any stable identity. So imagine you have three instances and you remove the middle one - say you change a list from ["a", "b", "c"] to ["a", "c"] and drive count off its length.
Terraform does not think "delete b." It thinks in terms of indices. Index 0 was a and still is a, fine. But index 1 was b and is now c, and index 2 (c) no longer exists. So the plan is: modify index 1 in place from b to c, and destroy index 2. For real infrastructure that usually means destroying and recreating c for no reason, taking an outage with it, all because you removed an element earlier in the list.
This is the single biggest reason to reach for for_each instead. Use count when the instances are genuinely interchangeable and you only ever grow or shrink from the end - a fixed pool of identical workers, or a simple on/off toggle. The moment the items have identity, count is a liability.
for_each: stable keys over a map or set
for_each iterates over a map or a set of strings, and each instance is keyed by its map key (or set value) rather than a position. That key is stable: it does not shift when other elements come and go. This is what makes for_each the default choice for anything that matters.
resource "aws_iam_user" "team" {
for_each = toset(["alice", "bob", "carol"])
name = each.value
}
Inside the block you get each.key and each.value (for a set they are the same). These are addressed by key: aws_iam_user.team["alice"]. Now remove bob from that set and re-plan. Terraform destroys exactly aws_iam_user.team["bob"] and leaves alice and carol completely untouched, because their keys never moved. That is the whole difference: no cascade, no accidental recreation.
Maps are where for_each really earns its place, because the key and the value carry meaning:
variable "buckets" {
type = map(object({
versioning = bool
acl = string
}))
default = {
logs = { versioning = false, acl = "private" }
backups = { versioning = true, acl = "private" }
assets = { versioning = true, acl = "public-read" }
}
}
resource "aws_s3_bucket" "this" {
for_each = var.buckets
bucket = "myapp-${each.key}"
}
resource "aws_s3_bucket_versioning" "this" {
for_each = var.buckets
bucket = aws_s3_bucket.this[each.key].id
versioning_configuration {
status = each.value.versioning ? "Enabled" : "Suspended"
}
}
Adding a fourth bucket is one new map entry. Removing one deletes only that bucket. And notice how the second resource keys off the first with aws_s3_bucket.this[each.key].id - because the keys are shared and stable, related resources line up cleanly.
count vs for_each: which to reach for
The short version: default to for_each, drop to count only for the two cases it is actually good at.
- Use
for_eachwhenever the instances have any identity - a name, an environment, a region, a distinct config. Anything you might add to or remove from the middle of. This is the vast majority of real loops. - Use
countfor a plain conditional (count = var.enabled ? 1 : 0), or for a fixed number of truly interchangeable copies you only ever scale from the tail end.
A note you will hit in practice: you cannot use both count and for_each on the same resource, and for_each needs its keys known at plan time. If the map's keys depend on values that only exist after another resource is created (an ID the provider hands back on apply), Terraform errors with "Invalid for_each argument." The fix is to key off something static you already know - names, not computed IDs - and look the computed value up inside the block.
depends_on: ordering Terraform cannot infer
Terraform builds a dependency graph automatically from references. If resource B mentions aws_iam_role.app.arn, Terraform knows the role must exist first and orders them for you. You almost never write ordering by hand, and you should not - implicit dependencies through references are the right way.
The exception is a hidden dependency with no reference to expose it. A classic case: an EC2 instance that needs an IAM policy to be attached before its startup script runs, but the instance config never actually references the policy attachment. Terraform sees no link, may create them in parallel, and the boot fails. depends_on states the ordering explicitly:
resource "aws_iam_role_policy" "app" {
role = aws_iam_role.app.id
policy = data.aws_iam_policy_document.app.json
}
resource "aws_instance" "app" {
ami = "ami-0abcd1234"
instance_type = "t3.small"
depends_on = [aws_iam_role_policy.app]
}
Reach for depends_on sparingly and only when there is a genuine ordering requirement with no natural reference to carry it. Sprinkling it everywhere "to be safe" serializes your graph and slows every apply. If you can express the dependency through a reference instead, do that.
The lifecycle block
lifecycle is a nested block that changes how Terraform handles create, update, and destroy for a resource. Three arguments matter.
create_before_destroy
By default, when a change forces replacement, Terraform destroys the old resource first and then creates the new one. For anything serving traffic that is a gap of downtime. create_before_destroy flips the order - build the replacement, then tear down the old one:
resource "aws_launch_template" "app" {
name_prefix = "app-"
image_id = var.ami_id
instance_type = "t3.small"
lifecycle {
create_before_destroy = true
}
}
This is the standard pattern for launch templates, TLS certificates, and anything fronted by a load balancer where a new one should be live before the old one dies. One catch: the two versions coexist briefly, so anything with a unique name will collide. That is why the example uses name_prefix rather than a fixed name - Terraform generates a unique suffix so both can exist at once.
prevent_destroy
A guardrail. Set it and Terraform refuses to run any plan that would destroy the resource, erroring out instead. It is a seatbelt for the things a stray terraform destroy or an accidental replacement would make you very unhappy about - a production database, a stateful volume, a hosted zone.
resource "aws_db_instance" "prod" {
identifier = "prod-db"
engine = "postgres"
instance_class = "db.t3.medium"
lifecycle {
prevent_destroy = true
}
}
Be aware it blocks the whole plan, not just the destroy, so when you genuinely do need to remove the resource you have to comment the flag out first. That friction is the point.
ignore_changes
Sometimes something outside Terraform legitimately mutates a resource, and you do not want Terraform to fight it back to the configured value on every apply. ignore_changes tells Terraform to stop diffing specific attributes after creation. The common example is an autoscaling group whose desired_capacity is driven by a scaling policy at runtime:
resource "aws_autoscaling_group" "app" {
max_size = 10
min_size = 2
desired_capacity = 2
lifecycle {
ignore_changes = [desired_capacity]
}
}
Without this, Terraform sets capacity back to 2 on every apply, undoing whatever the autoscaler decided. Another frequent use is ignoring tags that a separate cost-tagging system stamps on your resources. Use it precisely, listing only the attributes you mean - ignore_changes = all exists but it blinds Terraform to real drift, so avoid it.
dynamic blocks: repeatable nested config
count and for_each create whole resources. But sometimes the repetition is inside one resource - a security group with a variable number of ingress rules, an IAM policy with several statements. You cannot put count on a nested block, so Terraform gives you dynamic, which generates nested blocks by iterating a collection.
variable "ingress_ports" {
type = list(number)
default = [80, 443, 8080]
}
resource "aws_security_group" "web" {
name = "web"
dynamic "ingress" {
for_each = var.ingress_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
The dynamic "ingress" produces one ingress { ... } block per element. The iterator takes the block's name (ingress here), so you reference ingress.value and ingress.key inside content. It reads a little awkwardly at first, but it is exactly the same iteration idea applied to a nested block instead of a top-level resource.
A word of restraint: a static list of three plainly written ingress blocks is more readable than a dynamic block, and readability wins. Reach for dynamic when the number of blocks is genuinely driven by a variable or a map - not just to feel clever about a fixed, known set.
The shape of it
These meta-arguments turn Terraform from a list of hand-written resources into configuration that scales with your inputs. Use for_each by default so removing an item deletes only that item; drop to count for on/off conditionals and interchangeable pools, remembering that removing a middle element reindexes everything after it and can destroy resources you never touched. Let Terraform infer ordering from references, and only spell it out with depends_on when a real dependency has no reference to carry it. Use the lifecycle block deliberately: create_before_destroy to avoid downtime on replacement, prevent_destroy as a seatbelt on the resources you cannot afford to lose, and ignore_changes to stop fighting things that legitimately change outside Terraform. And use dynamic blocks when nested config genuinely varies with input, but not a moment before - a plain static block is almost always clearer.