Terraform Lesson 5 of 57

Terraform Resources & Meta-Arguments, In Depth: count, for_each, depends_on & lifecycle

The resource block is the heart of Terraform — it is the thing that actually creates infrastructure. You can write a perfectly valid configuration with no variables, no modules, and no outputs, but without at least one resource block Terraform does nothing. And yet most engineers use only a fraction of what a resource block can do. They know type, name, and a handful of arguments, and they reach for copy-paste the moment they need three of something. The difference between an engineer who uses Terraform and one who commands it lies almost entirely in the meta-arguments — the small set of special arguments (count, for_each, depends_on, provider, and the lifecycle block) that every resource type accepts regardless of which provider it comes from. They control how many of a resource exist, in what order resources are created and destroyed, which provider manages them, and how Terraform treats changes and deletions — including the safety rails that stop a plan from quietly destroying your production database.

This lesson is deliberately exhaustive. We start with the anatomy of a resource block and how every resource is addressed — because addressing is the thread that connects count, for_each, state operations, and the dependency graph. We cover data sources, the read-only cousin of resources. Then we go meta-argument by meta-argument: count and its index model and empty-list toggle; for_each over maps and sets, why its key-based addresses make it the right default, and the precise rules for converting from one to the other; depends_on and the difference between implicit and explicit dependencies; the provider meta-argument for multi-region and multi-account work; and finally the lifecycle block in full — create_before_destroy, prevent_destroy, ignore_changes (attributes and all), replace_triggered_by, and the often-missed precondition and postcondition validation blocks. We finish with the dependency graph that ties it all together and terraform_data for trigger-driven replacement. Everything here applies equally to OpenTofu, the open-source fork — the HCL and CLI are identical unless noted.

Learning objectives

After working through this lesson you will be able to:

Prerequisites

You should be comfortable reading basic HCL — blocks, arguments, and simple expressions — and you should have run the init → plan → apply → destroy workflow at least once. If the terms state, provider, and dependency graph are new, read Terraform Fundamentals: HCL, Providers, State & the Core Workflow first; this lesson assumes those mental models and goes deep on the resource layer that sits on top of them. This is a core Fundamentals lesson in the Terraform Zero-to-Hero ladder, sitting just after providers and just before variables. Meta-arguments are some of the most heavily tested topics on the Terraform Associate exam, so the depth here is exam-aligned as well as practical.

Core concepts: the resource block, anatomy and addressing

A resource block declares a single kind of infrastructure object that Terraform should create, read, update, and delete to match your configuration. Its skeleton is always the same:

resource "aws_instance" "web" {   # keyword "resource", a TYPE label, a NAME label
  ami           = "ami-0abc123"   # argument (provider-specific)
  instance_type = "t3.micro"      # argument (provider-specific)

  tags = {                        # nested argument (a map)
    Name = "web"
  }

  lifecycle {                     # a meta-argument BLOCK
    create_before_destroy = true
  }
}

Two labels follow the resource keyword. The first is the resource type (aws_instance) — it is defined by a provider and dictates which arguments are legal; the prefix before the first underscore (aws) tells Terraform which provider owns it. The second is the local name (web) — your choice, and it only has to be unique among resources of the same type within the same module. The type and name together form the resource’s address.

Addressing is the single most important idea in this lesson, because every meta-argument is really a statement about addresses. Terraform tracks each managed object in state by its address; terraform state commands, the dependency graph, plan output, and -target all speak in addresses. The forms you will meet:

Address form Means When it appears
aws_instance.web The single instance of a resource declared without count/for_each The default — one resource, one address
aws_instance.web[0] The instance at integer index 0 of a count-ed resource Whenever count is set (even count = 1)
aws_instance.web["app"] The instance keyed by string "app" of a for_each-ed resource Whenever for_each is set
module.network.aws_subnet.this[2] A resource inside a child module, indexed Resources created by modules
data.aws_ami.ubuntu A data source (read-only), addressed under the data. prefix Reads, never managed

Note the consequence that trips up everyone once: adding count or for_each to a resource that previously had neither changes its address. aws_instance.web becomes aws_instance.web[0] (or ["app"]), and Terraform — which keys state by address — sees the old address vanish and a new one appear. Without intervention it will plan to destroy and recreate the resource. The clean fix is a moved block (covered in the refactoring lesson, cross-linked below); the crude fix is terraform state mv. Keep this in mind through everything that follows.

A few more anatomy facts worth stating plainly:

Data sources: the read-only cousin

A data source uses a data block to read information about infrastructure that already exists — whether created outside Terraform, in another configuration, or by a different team — without managing it. It never appears in your destroy plan because Terraform does not own it.

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id   # consume the data source's attribute
  instance_type = "t3.micro"
}
Aspect resource block data block
Purpose Create/update/delete & manage an object Read attributes of an existing object
Address prefix type.name data.type.name
Lifecycle Full CRUD; appears in destroy Read-only; never destroyed
When it runs During apply (and refresh) During plan/refresh (or apply if its inputs are known-after-apply)
Meta-arguments count, for_each, depends_on, provider, lifecycle (all) count, for_each, provider, and depends_on; lifecycle supports precondition/postcondition only

Data sources take the same count and for_each meta-arguments as resources, addressed identically (data.aws_subnet.this["a"]). A subtle but important rule: if a data source’s arguments depend on values that are unknown until apply (for example a filter built from a not-yet-created resource), Terraform defers the read until apply, and any plan that depends on its results shows (known after apply). Adding depends_on to a data source forces the same deferral on purpose — useful when a read must happen after some resource is in place but there is no attribute to reference.

The meta-arguments, in full

Five meta-arguments are valid on (almost) every resource regardless of provider. Here they are at a glance before we take each in depth:

Meta-argument Form What it controls Valid on data sources?
count count = <number> How many identical instances; index-addressed [0], [1] Yes
for_each for_each = <map|set> One instance per element; key-addressed ["k"] Yes
depends_on depends_on = [<refs>] Explicit ordering when no attribute reference links resources Yes
provider provider = <type.alias> Which (aliased) provider configuration manages this resource Yes
lifecycle lifecycle { … } Create/destroy ordering, deletion protection, change-ignoring, replacement triggers, pre/post conditions Partly (conditions only)

A hard rule to memorise: count and for_each are mutually exclusive on the same block — you may use one or the other, never both.

count: the simplest multiplier

count accepts a whole number and creates that many instances of the resource, each addressed by a zero-based integer index and each exposing a count.index value inside the block.

resource "aws_instance" "worker" {
  count         = 3
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  tags = {
    Name = "worker-${count.index}"   # worker-0, worker-1, worker-2
  }
}
# Addresses: aws_instance.worker[0], [1], [2]
# Reference all: aws_instance.worker[*].id   (a list via the splat)
# Reference one: aws_instance.worker[1].private_ip

count.index is the only special symbol count provides, and it is an integer (0 to count − 1). To turn a list into indexed resources you typically index the list by count.index:

variable "subnet_cidrs" {
  type    = list(string)
  default = ["10.0.1.0/24", "10.0.2.0/24"]
}

resource "aws_subnet" "this" {
  count      = length(var.subnet_cidrs)
  vpc_id     = aws_vpc.main.id
  cidr_block = var.subnet_cidrs[count.index]
}

The conditional (zero-or-one) toggle

The most idiomatic use of count has nothing to do with counting many things — it is the on/off switch. Because count = 0 creates no instances, a boolean-driven count makes a resource conditional:

variable "create_bastion" {
  type    = bool
  default = false
}

resource "aws_instance" "bastion" {
  count         = var.create_bastion ? 1 : 0   # 1 = exists, 0 = absent
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}

# Referencing a maybe-absent resource needs the index and a guard:
output "bastion_ip" {
  value = var.create_bastion ? aws_instance.bastion[0].public_ip : null
}

This is the standard pattern for “deploy this only in prod” or “create the resource only if a feature flag is set”. Note that even a single conditional instance lives at index [0], never the bare address — referencing aws_instance.bastion.public_ip (no index) is an error when count is set.

The fatal flaw of count: positional addresses

count addresses by position, and positions shift. Suppose you manage three users from a list and remove the middle one:

variable "users" { default = ["alice", "bob", "carol"] }

resource "aws_iam_user" "team" {
  count = length(var.users)
  name  = var.users[count.index]
}
# State: team[0]=alice, team[1]=bob, team[2]=carol

Drop "bob", leaving ["alice", "carol"]. Now the list is shorter, so carol slides from index 2 to index 1. Terraform compares by address:

You asked to delete one user; Terraform proposes to destroy one and mutate another. For IAM users this is merely noisy; for stateful resources (a database, a disk, a persistent volume) this positional shuffle is data-destroying. This is the single most important reason to prefer for_each whenever the set of things has stable identities.

Use count when: the instances are genuinely identical and interchangeable and the number is what matters (a fixed-size pool of stateless workers), or you need the zero/one conditional toggle, or you are iterating a list where order is irrelevant and the list never has middle elements removed.

for_each: one instance per keyed element

for_each creates one instance per element of a map or a set of strings, addressing each by a stable string key rather than a position. Inside the block you get two symbols: each.key and each.value.

# --- over a SET of strings: key == value ---
resource "aws_iam_user" "team" {
  for_each = toset(["alice", "bob", "carol"])
  name     = each.key      # for a set, each.key == each.value
}
# Addresses: aws_iam_user.team["alice"], ["bob"], ["carol"]

# --- over a MAP: key and value differ ---
resource "aws_instance" "svc" {
  for_each      = {
    web   = "t3.small"
    cache = "r6g.large"
    db    = "m6i.xlarge"
  }
  ami           = data.aws_ami.ubuntu.id
  instance_type = each.value           # the map value
  tags          = { Name = each.key }  # the map key
}
# Addresses: aws_instance.svc["web"], ["cache"], ["db"]

Now remove "bob" from the set. Because addresses are keyed by name, Terraform sees exactly one change:

That is the whole point: for_each gives every instance a stable, meaningful address, so adding or removing one element touches only that element. This is why for_each is the modern default for any collection whose members have identities.

each.key / each.value reference table

Symbol With for_each over a set With for_each over a map
each.key The element’s string value The map key
each.value The element’s string value (same as key) The map value (any type)

The rules and constraints of for_each

for_each is stricter than count, and the constraints are exam favourites:

Rule Detail Why / fix
Argument type Must be a map or a set(string) A list is not allowed — convert with toset(...). Lists imply order/duplicates, which keys cannot have.
Keys must be known at plan time The keys (map keys / set members) cannot be values that are “known after apply” You may not key for_each on an attribute computed by another resource (e.g. a generated ID). Values can be unknown; keys cannot.
No duplicate keys A set cannot contain duplicates; map keys are unique by definition toset(["a","a"]) collapses to one — silently fewer instances than you might expect.
each only inside the block each.key/each.value are scoped to the resource using for_each Outside it, iterate with a for expression instead.
Splat differs for_each resources are a map of instances, not a list Use values(aws_instance.svc)[*].id or a for expression; bare [*] is for count.

The “keys must be known at plan time” rule is the one people hit hardest. If you write for_each = toset(aws_subnet.this[*].id) — keying on IDs that do not exist until apply — Terraform errors with “the for_each value depends on resource attributes that cannot be determined until apply”. The fix is to key on something you control (names, CIDRs) and look up the computed value inside, or to split the apply with -target as a last resort.

Turning a list into a map for for_each

Real input often arrives as a list of objects. The idiomatic conversion is a for expression that projects a stable key:

variable "subnets" {
  type = list(object({
    name = string
    cidr = string
    az   = string
  }))
}

resource "aws_subnet" "this" {
  for_each = { for s in var.subnets : s.name => s }   # key by the stable "name"
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az
  tags              = { Name = each.key }
}

Keying by s.name (not by count.index) means reordering the list, or inserting an entry in the middle, never disturbs the existing subnets — exactly the stability count cannot give you.

count vs for_each: the exhaustive comparison

This is the decision the rest of your Terraform life turns on. The full contrast:

Dimension count for_each
Accepts A whole number A map or set(string)
Instance address Integer index — name[0], name[1] String key — name["app"]
Special symbols count.index (int) each.key, each.value
Add/remove a middle element Re-indexes everything after it → cascade of changes/replacements Touches only the affected key
Address stability Positional — fragile Key-based — stable
Conditional create count = cond ? 1 : 0 (idiomatic) for_each = cond ? {...} : {} (works, more verbose)
Keys known at plan time? N/A (count can derive from computed length if the list itself is known) Keys must be known at plan time
Collection of instances A list — splat name[*] A mapvalues(name), name["k"]
Best for Fixed pools of identical, interchangeable things; on/off toggles Anything whose members have identities (named subnets, users, buckets, rules)
Duplicates Allowed (list values can repeat) Not allowed (set/map keys are unique)

The rule of thumb that interviewers want to hear: use for_each by default; reach for count only for a fixed number of truly interchangeable instances or for the zero/one conditional toggle. The reason is always the same — stable addresses prevent accidental destroy-and-recreate when the collection changes.

One more practical note: you can use count or for_each on modules too, with identical semantics (module.app["web"]), and on data sources. The address rules carry over unchanged.

depends_on: explicit ordering

Terraform almost never needs to be told about ordering, because it infers dependencies from references. When resource A’s argument reads resource B’s attribute, Terraform knows B must exist first. That is an implicit dependency, and it is the mechanism you should rely on 95% of the time:

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "app" {
  vpc_id     = aws_vpc.main.id   # implicit dependency: subnet waits for the VPC
  cidr_block = "10.0.1.0/24"
}

depends_on is the explicit override for the cases where a real dependency exists but is invisible to Terraform because no attribute connects the two resources. The classic example is an IAM policy that must be attached before an instance can use it, where the instance configuration never references the policy:

resource "aws_iam_role_policy" "s3" {
  role   = aws_iam_role.app.id
  policy = data.aws_iam_policy_document.s3.json
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  iam_instance_profile = aws_iam_instance_profile.app.name

  # The app's startup script writes to S3, which only works once the
  # role policy is attached — but nothing here *references* that policy:
  depends_on = [aws_iam_role_policy.s3]
}
Dependency kind How it is created Use it when
Implicit One resource references another’s attribute (vpc_id = aws_vpc.main.id) Always, if any attribute links them — it is automatic and precise
Explicit (depends_on) A literal list of resource/module addresses with no attribute reference A hidden ordering exists (eventual-consistency, side effects, IAM, “must be ready before”) that Terraform cannot see

Rules and cautions for depends_on:

The provider meta-argument

By default a resource is managed by the default configuration of its provider (the unlabelled provider "aws" {} block). When you have multiple configurations of the same provider — for multiple regions or accounts — you give the extra ones an alias and select one per resource with the provider meta-argument.

provider "aws" {                 # default — us-east-1
  region = "us-east-1"
}

provider "aws" {                 # aliased — eu-west-1
  alias  = "europe"
  region = "eu-west-1"
}

resource "aws_s3_bucket" "us" {
  bucket = "kv-data-us"          # uses the DEFAULT provider implicitly
}

resource "aws_s3_bucket" "eu" {
  provider = aws.europe          # explicitly use the aliased config
  bucket   = "kv-data-eu"
}

Key facts: the value is <provider>.<alias> (no quotes — it is a reference, not a string); without it the default configuration is used; and for modules you pass aliased providers down via the providers = { aws = aws.europe } map rather than the provider meta-argument. Aliases and version constraints are covered fully in Terraform Providers, In Depth: required_providers, Versions, Aliases & the Lock File; here we only need the meta-argument that consumes them.

The lifecycle block: every option

The lifecycle nested block changes how Terraform treats the resource’s creation, replacement, deletion, and change-detection. Its arguments take literal values only (with one exception, ignore_changes, which takes attribute references). Here is the full surface before we take each in turn:

Option Type Purpose One-line gotcha
create_before_destroy bool Create the replacement before destroying the old one (zero-downtime replace) The new object must not clash with the old on any unique name/port/identifier
prevent_destroy bool Make any plan that would destroy this resource error out Blocks terraform destroy too; must be removed (not just toggled) to delete
ignore_changes list of attribute refs, or all Stop Terraform reverting drift on the listed attributes Suppresses updates to those attrs; does not stop replacement triggered elsewhere
replace_triggered_by list of refs Force replacement when a referenced resource/attribute changes References must be to managed resources/attributes, not variables
precondition block (condition + error_message) Assert an assumption before the resource is created/updated Evaluated during plan; fails fast with your message
postcondition block (condition + error_message) Assert a guarantee about the result after apply Can reference self; catches bad outcomes the type cannot prevent
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  lifecycle {
    create_before_destroy = true
    prevent_destroy       = false
    ignore_changes        = [tags["LastScanned"], ami]
    replace_triggered_by  = [terraform_data.deploy_version]

    precondition {
      condition     = data.aws_ami.ubuntu.architecture == "x86_64"
      error_message = "The selected AMI must be x86_64 for this instance family."
    }
    postcondition {
      condition     = self.public_ip != ""
      error_message = "The instance was created without a public IP; check the subnet's auto-assign setting."
    }
  }
}

create_before_destroy

By default Terraform destroys then creates when an attribute change forces a replacement — there is a window where the resource does not exist. Setting create_before_destroy = true inverts this: Terraform creates the replacement first, switches references to it, then destroys the old one. This is how you achieve zero-downtime replacement of things like launch configurations, instances behind a load balancer, or TLS certificates.

prevent_destroy

prevent_destroy = true makes Terraform reject any plan that would destroy this resource, erroring at plan time before anything happens. It is the guardrail for irreplaceable, stateful resources — production databases, the state bucket itself, a KMS key.

ignore_changes

ignore_changes tells Terraform to stop trying to “correct” specified attributes when they drift from the configuration. It takes a list of attribute references (bare names, not strings):

lifecycle {
  ignore_changes = [
    tags["LastModifiedBy"],   # an autoscaler or another system writes this tag
    desired_count,            # an autoscaler manages the count; don't fight it
  ]
}

After the resource is created, Terraform will no longer plan updates to those attributes even if the real value differs from the config. The canonical use cases are attributes managed by something other than Terraform — autoscaling adjusting desired_count, a deployment system bumping an image tag, a platform stamping tags, or an initial_password you set once and the cloud then rotates.

replace_triggered_by

replace_triggered_by (added in Terraform 1.2 / present in OpenTofu) forces this resource to be replaced whenever a referenced resource or attribute changes, even though nothing about this resource’s own arguments changed. It takes a list of references to managed resources, instances, or their attributes:

resource "terraform_data" "deploy" {
  input = var.app_version    # changes when you ship a new version
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  lifecycle {
    # Replace the instance every time app_version changes, even though
    # none of the instance's own arguments changed:
    replace_triggered_by = [terraform_data.deploy]
  }
}

Use it to couple replacement to an external signal — “rebuild the instance when the deployment version changes”, “recreate the cache when the config object changes”. References must point at managed resources/attributes (you cannot replace_triggered_by a variable directly — wrap the variable in a terraform_data resource, as above). Referencing a whole resource triggers replacement when any of its attributes change; referencing a single attribute narrows the trigger.

precondition and postcondition (custom condition checks)

These two nested blocks turn assumptions and guarantees into enforced, self-documenting assertions — part of Terraform’s custom conditions feature (1.2+, and in OpenTofu). Each contains a condition (a boolean expression that must be true) and an error_message (shown when it is false).

data "aws_ec2_instance_type" "this" {
  instance_type = var.instance_type
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type
  subnet_id     = var.subnet_id

  lifecycle {
    precondition {
      condition     = data.aws_ec2_instance_type.this.ebs_optimized_support != "unsupported"
      error_message = "instance_type ${var.instance_type} does not support EBS optimisation, which this workload requires."
    }
    postcondition {
      condition     = self.private_ip != ""
      error_message = "Instance launched without a private IP — the subnet may be misconfigured."
    }
  }
}
Aspect precondition postcondition
When evaluated Before create/update (during plan, as soon as its inputs are known) After the resource’s values are known (plan if known, else apply)
Can reference self? No (the resource does not exist yet) Yes — its own attributes
Typical use Validate inputs/assumptions before building Validate outcomes/guarantees after building
On failure Halts with your error_message Halts with your error_message
Valid on data sources? Yes Yes

Where do these sit among Terraform’s other validation tools? It is worth fixing the boundaries, because the exam and interviewers probe it:

Check Lives in Best for
Variable validation variable block Validating a single input value in isolation (a string matches a regex, a number is in range)
precondition resource/data lifecycle Cross-cutting assumptions about inputs/other data before a specific resource is built
postcondition resource/data lifecycle Guarantees about a resource’s own result (self.*) after it is built
check block top-level check (1.5+) Non-blocking assertions/health checks that warn but never fail the apply

The distinction that matters: variable validation is scoped to one variable; pre/postconditions can reference multiple values and a resource’s own attributes; and a check block (a separate top-level construct) reports problems as warnings without stopping the apply, which the others always do.

terraform_data and trigger-driven replacement

Often you want to force something to happen when an arbitrary value changes — re-run a provisioner, replace a resource (via replace_triggered_by), or simply store a value in state to depend on. The historical tool was the null_resource from the null provider with its triggers map. Terraform 1.4 introduced a built-in replacement that needs no provider: terraform_data (OpenTofu has it too).

resource "terraform_data" "config_version" {
  input = var.config_hash      # store any value in state

  triggers_replace = [         # replace this resource when any of these change
    var.config_hash,
    var.app_version,
  ]
}
Need Old way Modern way
Store a value in state to depend on null_resource + triggers terraform_data with input
Force replacement when X changes null_resource triggers + downstream depends_on terraform_data + triggers_replace, consumed by replace_triggered_by
Run a provisioner on change null_resource + provisioner terraform_data + provisioner (still a last resort)

The dependency graph: how it all orders

Everything above feeds one thing: the dependency graph. Before any apply, Terraform builds a directed acyclic graph (DAG) whose nodes are resource instances and whose edges are dependencies. It derives edges from:

  1. Implicit references — every attribute one resource reads from another.
  2. Explicit depends_on — edges you add by hand.
  3. replace_triggered_by and provider relationships — additional ordering constraints.

Terraform then walks the graph: it creates/updates resources in dependency order, parallelising independent branches (up to -parallelism, default 10), and on destroy it walks the graph in reverse, tearing down dependents before the things they depend on. count and for_each expand a single configured resource into multiple graph nodes (one per index/key), which is why a re-indexing change ripples through the graph. You can render the graph with terraform graph | dot -Tsvg > graph.svg.

Terraform resource meta-arguments and the lifecycle/dependency model

The diagram shows a single resource block fanning out via count (positional [0..n]) and for_each (keyed ["a".."z"]) into distinct graph nodes, the implicit and depends_on edges that order them, and the lifecycle decision points — create_before_destroy reversing the destroy/create order, prevent_destroy halting a delete, ignore_changes filtering drift, and precondition/postcondition gating plan and apply.

Hands-on lab

We will do this entirely free and local using the local and random providers — no cloud account, no cost — so you can see count, for_each, and the whole lifecycle surface behave for real. You only need the Terraform (or OpenTofu) binary.

1. Scaffold the project. Create a folder tf-meta and a file main.tf:

terraform {
  required_version = ">= 1.5"
  required_providers {
    local  = { source = "hashicorp/local",  version = "~> 2.5" }
    random = { source = "hashicorp/random", version = "~> 3.6" }
  }
}

# --- count: a fixed pool, index-addressed ---
resource "random_pet" "worker" {
  count = 3
}

resource "local_file" "worker" {
  count    = length(random_pet.worker)
  filename = "${path.module}/out/worker-${count.index}.txt"
  content  = "worker ${count.index}: ${random_pet.worker[count.index].id}\n"
}

# --- for_each: keyed instances over a map ---
variable "services" {
  type = map(string)
  default = {
    web   = "frontend"
    cache = "redis"
    db    = "postgres"
  }
}

resource "local_file" "service" {
  for_each = var.services
  filename = "${path.module}/out/service-${each.key}.txt"
  content  = "service ${each.key} runs ${each.value}\n"

  lifecycle {
    create_before_destroy = true
    precondition {
      condition     = length(each.key) <= 10
      error_message = "Service key '${each.key}' exceeds 10 characters."
    }
  }
}

# --- terraform_data driving replacement ---
variable "config_version" {
  type    = string
  default = "v1"
}

resource "terraform_data" "deploy" {
  input            = var.config_version
  triggers_replace = [var.config_version]
}

resource "local_file" "manifest" {
  filename = "${path.module}/out/manifest.txt"
  content  = "deployed config: ${var.config_version}\n"

  lifecycle {
    replace_triggered_by = [terraform_data.deploy]
  }
}

output "service_files" {
  value = { for k, f in local_file.service : k => f.filename }
}

2. Initialise and apply.

terraform init
terraform apply -auto-approve

Expected: Terraform creates 3 random_pet, 3 worker-N.txt, 3 service-<key>.txt, the terraform_data.deploy, and manifest.txt. List them: ls out/ should show worker-0.txt worker-1.txt worker-2.txt service-web.txt service-cache.txt service-db.txt manifest.txt.

3. Watch for_each’s stable addressing. Remove cache from the services map and add queue = "rabbitmq", then plan:

terraform plan

Expected: exactly one destroy (local_file.service["cache"]) and one create (local_file.service["queue"]). The web and db files are untouched — their keys never moved. This is the payoff of for_each.

4. Contrast with count’s re-indexing. Temporarily change the worker pool from count = 3 to count = 2 and plan:

terraform plan

Expected: local_file.worker[2] is destroyed. Now imagine instead you had keyed workers off a list and removed a middle element — every later index would show as a change. Restore count = 3.

5. See replace_triggered_by fire. Change config_version to "v2":

terraform apply -auto-approve -var 'config_version=v2'

Expected: terraform_data.deploy is replaced, which forces local_file.manifest to be replaced (destroy/create), even though nothing about the manifest’s own content arguments changed beyond the version string.

6. Trip a precondition. Add a service with an over-long key, e.g. analyticsdashboard = "metabase", and plan:

terraform plan

Expected: the apply is refused with “Service key ‘analyticsdashboard’ exceeds 10 characters.” — the precondition caught a bad input before creating anything. Remove the entry.

7. Cleanup.

terraform destroy -auto-approve

Then remove the folder. Cost note: the local, random, and terraform_data resources create only files on your machine and state entries — there is zero cloud cost. This entire lab is free.

Common mistakes & troubleshooting

Symptom Likely cause Fix
Adding count/for_each plans to destroy & recreate an existing resource The address changed (namename[0]/name["k"]); state keys by address Add a moved block, or terraform state mv the old address to the new — see the refactoring lesson
Error: Invalid for_each argument … cannot be determined until apply You keyed for_each on a computed/known-after-apply value (e.g. a resource ID) Key on a value known at plan time (names/CIDRs); look up the computed value inside via each.key
Removing one list element causes many resources to change count re-indexes positionally Migrate the resource to for_each keyed by a stable identity
Error: Reference to undeclared resource when referencing a count=0 resource You referenced name.attr instead of name[0].attr, or the resource has 0 instances Use the index and guard with a conditional (cond ? name[0].attr : null)
create_before_destroy fails with a name/identifier conflict A unique name exists on both old and new during the overlap Use name_prefix (provider-generated unique name) instead of a fixed name
terraform destroy errors and refuses to delete a resource prevent_destroy = true is set on it Remove the prevent_destroy line (it must be a literal), apply, then destroy
ignore_changes does not stop a resource from being replaced ignore_changes only suppresses in-place updates; a different attribute or replace_triggered_by is forcing the replace Identify the replace-forcing attribute in the plan; ignore that, or accept the replacement
for_each silently creates fewer instances than expected Duplicate set members collapsed (toset dedupes) or a for projection produced colliding keys Ensure the key expression is unique per element
depends_on makes applies slow False/over-broad explicit dependencies serialise the graph Replace with a real attribute reference; only keep depends_on for genuinely hidden deps

Best practices

Security notes

Meta-arguments shape your security posture more than they appear to. prevent_destroy is a control-plane safety rail, not a security control: it stops Terraform deleting a resource but does nothing against a console user, so pair it with cloud-native deletion protection and least-privilege IAM on the principal Terraform runs as. ignore_changes can quietly hide drift that matters for security — if you ignore changes to a security group’s rules or a bucket policy because “something else manages them”, you also lose Terraform’s ability to detect that those rules were tampered with; ignore only attributes whose ownership genuinely lies elsewhere, and consider a check block to alert on the ignored values. precondition/postcondition are excellent security gates: assert that an AMI comes from an approved owner, that a bucket is in an allowed region, that encryption is enabled — failing the apply rather than shipping a misconfiguration. Finally, remember that for_each/count over secrets (looping to create many secret values) still writes every value to state in plaintext; the iteration does not change the state-sensitivity rules, so protect the backend accordingly.

Interview & exam questions

1. When would you choose for_each over count, and why? Use for_each whenever the instances have stable identities (named subnets, users, buckets). count addresses by integer position, so removing or inserting a middle element re-indexes everything after it, causing spurious changes or destructive recreation. for_each addresses by key, so adding/removing one element touches only that element.

2. Give the one case where count is clearly the right tool. The zero/one conditional toggle — count = var.enabled ? 1 : 0 — to make a resource conditional. Also a fixed-size pool of genuinely interchangeable, stateless instances where position is irrelevant.

3. What types can for_each accept, and what is the constraint on its keys? A map or a set(string) (not a list — convert with toset). The keys must be known at plan time; they cannot depend on values computed during apply (like a resource ID). Values may be unknown; keys may not.

4. What is the difference between an implicit and an explicit dependency? Implicit: created automatically when one resource references another’s attribute. Explicit: declared with depends_on when a real ordering exists but no attribute links the resources (IAM attachment, eventual consistency, side effects). Prefer implicit; depends_on removes parallelism.

5. Walk me through every lifecycle option. create_before_destroy (create the replacement before destroying the old — zero-downtime, but watch unique names); prevent_destroy (error on any plan that would delete it); ignore_changes (stop reverting drift on listed attrs, or all); replace_triggered_by (replace when a referenced resource/attr changes); precondition (assert before create/update); postcondition (assert about the result, can use self).

6. ignore_changes = [ami] is set, yet the plan wants to replace the instance. Why? ignore_changes only suppresses in-place updates to the named attributes. A different attribute changing (e.g. instance_type), or a replace_triggered_by/-replace, can still force a full replacement. Ignoring ami does not pin the whole resource.

7. Difference between a variable validation block, a precondition, and a check block? validation validates a single input variable in isolation. precondition/postcondition live in a resource/data lifecycle and can reference multiple values (and self for postconditions); they fail the plan/apply. A top-level check block runs non-blocking assertions that emit warnings without failing the apply.

8. What does create_before_destroy do to a resource with a fixed unique name, and how do you fix it? It fails: the new instance is created while the old still holds the unique name/identifier, causing a conflict. Fix by using name_prefix so the provider generates a unique name, letting both coexist during the swap.

9. How do you make a resource rebuild whenever an external version string changes? Wrap the version in a terraform_data resource (triggers_replace = [var.version]) and reference it from the target resource’s lifecycle { replace_triggered_by = [terraform_data.deploy] }. (replace_triggered_by must reference a managed resource/attribute, not a variable.)

10. You add for_each to an existing single resource and the plan wants to destroy and recreate it. What happened and what is the clean fix? Its address changed from type.name to type.name["key"]; Terraform keys state by address, so the old address looks deleted and the new one new. The clean fix is a moved { from = ..., to = ... } block (or terraform state mv) so Terraform treats it as a rename, not a replacement.

11. What replaced null_resource + triggers, and why is it better? terraform_data, built into Terraform/OpenTofu core. It needs no null provider, supports input/output for pass-through values, and triggers_replace for change-driven replacement.

12. On a module, what does depends_on affect? Every resource inside the module waits for the listed dependencies — it is module-wide. It is blunt; prefer wiring real attribute references between modules where possible.

Quick check

  1. count addresses instances by ______; for_each addresses them by ______.
  2. True or false: for_each accepts a list(string) directly.
  3. Which lifecycle option creates the replacement before destroying the old resource?
  4. You need a resource only when var.enabled is true. Write the meta-argument.
  5. Which two lifecycle blocks are also valid on a data source, and which one can reference self?

Answers

  1. Integer index (name[0]); string key (name["k"]). Position vs identity.
  2. False. It accepts a map or set(string); wrap a list with toset(...).
  3. create_before_destroy = true — inverts the default destroy-then-create order for zero-downtime replacement.
  4. count = var.enabled ? 1 : 0 (the idiomatic conditional toggle). The instance lives at index [0].
  5. precondition and postcondition are valid on data sources (the other lifecycle options are not). Only postcondition can reference self, because the object exists by then.

Exercise

Take the lab’s for_each services map and harden it like production:

  1. Convert var.services from map(string) to a list(object({ name = string, role = string, replicas = number })), then build the for_each map with a for expression keyed by name ({ for s in var.services : s.name => s }).
  2. Resist the urge to nest a count for the replicas. Keep exactly one local_file per service (for_each), and express the replica count as an attribute of the content rather than as multiple resources. (This is the discriminator: distinct identities → for_each; a mere quantity within one identity → an attribute, not count.)
  3. Add a precondition to the local_file.service resource asserting every replicas value is between 1 and 5, with a clear error_message that interpolates each.key.
  4. Add a postcondition asserting the generated content is non-empty (length(self.content) > 0).
  5. Add a terraform_data “release” resource keyed off a var.release_id, and make local_file.manifest use replace_triggered_by so changing release_id rebuilds the manifest.
  6. apply; then reorder the list and confirm via plan that no existing service file changes (proving the for_each key is stable). Finally destroy.

Write two or three sentences contrasting what step 6’s plan showed against what count keyed off the list index would have shown — this stable-address contrast is the single most common Terraform interview discriminator.

Certification mapping

This lesson maps to the HashiCorp Certified: Terraform Associate (003) exam, principally the objectives Read, generate, and modify configuration and Understand Terraform basics. Specifically it covers: the resource block and data sources; the meta-arguments count, for_each, depends_on, and provider; resource addressing and how count/for_each change it; the lifecycle block — create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, and precondition/postcondition; the dependency graph and implicit-vs-explicit dependencies; and terraform_data as the modern trigger primitive. Expect the exam to test count vs for_each (the re-indexing problem), the for_each plan-time key constraint, and what each lifecycle option does — all covered above. The adjacent Terraform Associate Prep Kit lesson drills these as practice questions.

Glossary

Next steps

You now command the resource layer — addressing, data sources, count vs for_each, dependencies, the provider meta-argument, and every lifecycle option including the pre/post condition checks. The natural next move is the layer that feeds these resources: inputs and their rules. Continue with Terraform Variables, Outputs & Locals, In Depth: Types, Validation, Sensitivity & Precedence, which covers variable type constraints (including optional() object attributes), validation blocks, the full variable-precedence order, outputs, and locals — the parameterisation that turns the resources you just mastered into reusable configuration. And when you need to restructure count/for_each or rename resources without destroying them, see Refactoring Terraform Safely: moved, import & removed Blocks.

TerraformIaCMeta-argumentsfor_eachlifecycleOpenTofu
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments