Terraform Lesson 5 of 89

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.

In a nutshell

Level: Intermediate · Time: ~43 min

Every resource block you write describes one kind of thing — a server, a bucket, a DNS record. The meta-arguments are the small set of knobs that sit on the outside of that block, and they are identical on every resource from every provider. They do not describe what the thing is; they describe how Terraform should handle it.

Think of a resource block as an order form at a factory. The arguments inside the block are the product spec — colour, size, model. The meta-arguments are the four boxes on the outside of the form:

The single idea that makes all four click is the address. Terraform remembers every object it built by a label — aws_instance.web, aws_instance.web[0], aws_instance.web["db"] — and it decides what to create, change, or destroy purely by comparing the labels in your code against the labels in its records. Change a label and Terraform does not see “the same thing, renamed”; it sees one thing vanish and another appear. That is why count, which labels by position, is dangerous for lists that change in the middle, and why for_each, which labels by name, is the modern default. Almost everything else in this lesson follows from that one sentence.

You need to be comfortable reading HCL and to have run init → plan → apply at least once. By the end you will be able to pick count or for_each deliberately and defend the choice, migrate between them without destroying anything, express hidden ordering with depends_on, and use every lifecycle option — including the pre/post condition checks that turn quiet assumptions into enforced, self-documenting rules.

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.

Going deeper

Everything above is enough to write correct configuration from scratch. This section is for the engineer who has to change configuration that is already running — migrate between the multipliers, force a single instance to rebuild, and reason about the order Terraform tears things down in.

Migrating countfor_each without destroying anything

This lesson has twice noted that changing a resource’s address makes Terraform see a destroy plus a create. Migrating from count to for_each changes every address at once, so a naive edit proposes to destroy and recreate the entire set. The fix is a moved block (Terraform 1.1+, and in OpenTofu): a config-only instruction that says “this old address is now that new address — treat it as a rename, not a replacement.”

Start from the fragile version:

variable "users" {
  type    = list(string)
  default = ["alice", "bob", "carol"]
}

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

Convert the resource to for_each, then add one moved block per existing instance, mapping the old integer index to the new string key:

resource "aws_iam_user" "team" {
  for_each = toset(var.users)
  name     = each.key
}

moved {
  from = aws_iam_user.team[0]
  to   = aws_iam_user.team["alice"]
}
moved {
  from = aws_iam_user.team[1]
  to   = aws_iam_user.team["bob"]
}
moved {
  from = aws_iam_user.team[2]
  to   = aws_iam_user.team["carol"]
}

terraform plan should now report no changes — only the address rewrites. That “no changes” line is the entire test: if the plan still shows a destroy, one of your from/to pairs is wrong, and the correct response is to fix the block, not to accept the plan.

Points that matter in practice:

Three config-only blocks cover the whole refactoring surface, and it is worth knowing which one you want:

Block Since What it says What it does to real infrastructure
moved 1.1 “This address is now that address” Nothing — a state rename
import 1.5 “This existing real object belongs at this address” Nothing — adopts it into state
removed 1.7 “Stop managing this address” Nothing — forgets it, leaves the object alive

All three are covered end-to-end in Refactoring Terraform Safely: moved, import & removed Blocks.

Surgical operations on a single instance: -replace and -target

Because count and for_each expand one block into many graph nodes, you frequently need to act on exactly one of them. Two CLI flags take a resource address and do that — and both are break-glass tools, not routine workflow.

-replace forces one instance to be destroyed and recreated on the next apply even though its configuration has not changed. It is the modern successor to the deprecated terraform taint/untaint commands:

# Plan the replacement first — always
terraform plan -replace='aws_instance.svc["cache"]'

# Then carry it out
terraform apply -replace='aws_instance.svc["cache"]'

-target restricts an operation to the given address and everything it depends on, skipping the rest of the configuration:

terraform apply -target='aws_subnet.this["public-a"]'

Note the quoting. A keyed address contains double quotes, so wrap the whole address in single quotes in bash/zsh ('aws_instance.svc["cache"]') or escape the inner quotes. Forgetting this is the most common reason these commands appear to “do nothing” — the shell strips the quotes and the address no longer matches anything.

Flag What it does Legitimate use Why it is dangerous
-replace=ADDR Marks one instance for destroy-and-recreate on the next apply A resource is in a bad runtime state its configuration cannot express (a corrupted VM, a poisoned cache) It destroys something; on a stateful resource that is data loss
-target=ADDR Applies only that address and its dependencies Unsticking a broken dependency chain or finishing a partially-failed apply Leaves the rest of the configuration un-reconciled, hiding drift — Terraform prints a warning after every targeted run

Neither belongs in a pipeline. If you find yourself reaching for -target routinely, the real problem is almost always that one configuration has grown too large and should be split into separately-applied layers.

for_each edge cases the documentation glosses over

Three failure modes account for most for_each frustration once you move past simple maps.

A set of objects is not allowed — only a set of strings. for_each must derive a string key from each element. toset([{name="a"}, {name="b"}]) produces a set of objects and Terraform rejects it. Project a map instead, keyed by the stable field:

# WRONG — a set of objects has no string key to address by
for_each = toset(var.subnets)

# RIGHT — a map keyed by a stable, unique string
for_each = { for s in var.subnets : s.name => s }

Keys must be known at plan time, and “known” is stricter than it first looks. The rule is not merely “no resource IDs”. A key derived from anything Terraform cannot resolve during plan fails identically: an attribute of a not-yet-created resource, a value from a data source whose own arguments are unknown, or a value from a resource that a -target run has not created yet. Values may be unknown; keys may not. When you genuinely cannot know the keys, the honest answers are to key on something you do control and look the computed value up inside the block, or to split the work into two configurations applied in sequence.

Sensitive values cannot be keys. If you build a for_each map from a value marked sensitive, Terraform refuses, because those keys would be printed in plan output and baked into resource addresses. Key on a non-sensitive identifier and keep the secret in the value, which is allowed.

Chaining is where for_each starts to pay off structurally. Because a for_each resource is a map of instances, you can iterate the parent map to build the child and the keys line up on both sides:

resource "aws_subnet" "this" {
  for_each          = { for s in var.subnets : s.name => s }
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az
}

resource "aws_route_table_association" "this" {
  for_each       = aws_subnet.this          # iterate the resource map itself
  subnet_id      = each.value.id            # each.value is the whole subnet object
  route_table_id = aws_route_table.main.id
}

Iterating aws_subnet.this directly is legal precisely because its keys ("public-a", …) are known at plan time even though the subnet IDs are not — a clean illustration of the keys-versus-values rule. The same works on modules: for_each on a module block yields module.app["web"], with identical addressing semantics.

ignore_changes for secrets — and the modern alternative

The classic reason to reach for ignore_changes is a secret: you set a database’s password once, the platform (or a human) rotates it, and every subsequent plan wants to “correct” it back. ignore_changes = [password] silences that, at the cost of Terraform no longer noticing any change to that attribute.

Terraform 1.11+ offers a cleaner answer for the subset of cases where the provider supports it: a write-only argument. These are provider-declared arguments — conventionally suffixed _wo, such as password_wo, paired with a companion password_wo_version — whose values are sent to the provider but never persisted to state or plan:

resource "aws_db_instance" "main" {
  # …
  password_wo         = var.db_password   # best fed from an ephemeral source
  password_wo_version = 1                 # increment this to re-send a new value
}

Because the value is never written to state, Terraform never reads it back, so it can never drift — and there is nothing to ignore. You re-send a value by incrementing the companion _wo_version argument rather than by changing something Terraform would diff. This is version-gated (1.11+) and provider-dependent: only arguments a provider has explicitly marked write-only behave this way, so check the resource’s documentation before assuming one exists. Where it does, prefer it over ignore_changes for secrets; where it does not, ignore_changes plus a locked-down state backend remains the honest fallback.

The wider principle is worth stating plainly: ignore_changes is an assertion that another system owns this attribute. If nothing else actually owns it, ignoring it is not a fix — it is a way of hiding a diff you did not want to read.

How destroy actually orders itself

Create order is easy to picture: dependencies first. Destroy is the mirror image, and the details start to matter the moment an apply fails halfway.

Terraform destroys by walking the same dependency graph in reverse — dependents are torn down before the things they depend on, because a subnet cannot be deleted while an instance still sits inside it. Independent branches are still destroyed in parallel, subject to -parallelism.

create_before_destroy complicates this, and understanding why explains its “infectiousness”. For a normal replacement the node order is destroy old → create new. Setting create_before_destroy = true rewrites that portion of the graph into create new → update dependents → destroy old. Any resource that depends on the replaced one now sits between the create and the destroy, so it too must tolerate that ordering — which is why Terraform propagates the setting to dependents automatically, and why you occasionally see an error saying the graph cannot be ordered when something in the chain cannot honour it. The practical rule: when you enable create_before_destroy on a resource that has dependents, expect those dependents to be replaced as well.

Two further destroy-time facts routinely surprise people:

When a destroy is genuinely stuck — the real object is already gone, or the provider errors on delete — the tools are terraform state rm (forget the object without deleting it) and, from Terraform 1.7, the declarative removed block, which does the same thing in configuration so the whole team’s state converges. Neither deletes anything in the cloud; both are ways of telling Terraform to stop managing an address.

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

Common beginner mistakes

These are misconceptions rather than error messages — the wrong mental model that produces the symptoms in the table above. Fix the model and the errors stop recurring.

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.

Practice challenges

Six graded exercises, beginner to advanced. Write your answer before opening the solution — the reasoning matters more than the syntax, and every solution ends with the one-line reason it is correct.

Challenge 1 — Make a resource optional (beginner)

Add a local_file that is created only when var.enable_debug_log is true, plus an output that returns its filename when it exists and null when it does not.

<details> <summary>Solution</summary>

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

resource "local_file" "debug" {
  count    = var.enable_debug_log ? 1 : 0
  filename = "${path.module}/out/debug.txt"
  content  = "debug enabled\n"
}

output "debug_file" {
  value = var.enable_debug_log ? local_file.debug[0].filename : null
}

Why: count = 0 creates no instances, which is the idiomatic on/off switch — and because count is set, the single instance still lives at [0], so the output must both index it and guard for the absent case.

</details>

Challenge 2 — Swap positional addresses for stable ones (beginner)

Rewrite this so that removing the middle region touches only that one file:

variable "regions" { default = ["ap-south-1", "eu-west-1", "us-east-1"] }

resource "local_file" "region" {
  count    = length(var.regions)
  filename = "${path.module}/out/${var.regions[count.index]}.txt"
  content  = "region ${var.regions[count.index]}\n"
}

<details> <summary>Solution</summary>

resource "local_file" "region" {
  for_each = toset(var.regions)
  filename = "${path.module}/out/${each.key}.txt"
  content  = "region ${each.key}\n"
}

Why: toset converts the list into a set(string), so each instance is addressed by name (local_file.region["eu-west-1"]) instead of position, and deleting a middle element no longer re-indexes everything after it.

</details>

Challenge 3 — Key a list of objects, and filter it (intermediate)

Given a list(object({ name = string, cidr = string, public = bool })), create one file per entry, keyed by name, but only for entries where public is true.

<details> <summary>Solution</summary>

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

resource "local_file" "public_subnet" {
  for_each = { for s in var.subnets : s.name => s if s.public }
  filename = "${path.module}/out/subnet-${each.key}.txt"
  content  = "${each.key} = ${each.value.cidr}\n"
}

Why: a for expression projects the list into a map keyed by the stable name field and the trailing if clause filters it — both resolve at plan time, so the keys are known and the addresses stay stable.

</details>

Challenge 4 — Migrate countfor_each with no destroys (intermediate)

aws_iam_user.team currently uses count = length(var.users) with var.users = ["alice", "bob", "carol"]. Convert it to for_each keyed by username and make terraform plan report no changes.

<details> <summary>Solution</summary>

resource "aws_iam_user" "team" {
  for_each = toset(var.users)
  name     = each.key
}

moved {
  from = aws_iam_user.team[0]
  to   = aws_iam_user.team["alice"]
}
moved {
  from = aws_iam_user.team[1]
  to   = aws_iam_user.team["bob"]
}
moved {
  from = aws_iam_user.team[2]
  to   = aws_iam_user.team["carol"]
}

Confirm the real index→name mapping with terraform state list before writing the blocks — never assume it matches today’s list order.

Why: moved blocks tell Terraform the object at the old address is the object at the new one, turning what would be three destroys and three creates into a pure state rewrite; a plan reporting “no changes” is the proof you mapped them correctly.

</details>

Challenge 5 — Turn assumptions into enforced rules (advanced)

On a for_each-ed local_file.service, add a precondition requiring every replicas value to be between 1 and 5 (naming the offending service in the message) and a postcondition asserting the generated content is not empty.

<details> <summary>Solution</summary>

resource "local_file" "service" {
  for_each = { for s in var.services : s.name => s }
  filename = "${path.module}/out/service-${each.key}.txt"
  content  = "${each.key} runs ${each.value.replicas} replica(s)\n"

  lifecycle {
    precondition {
      condition     = each.value.replicas >= 1 && each.value.replicas <= 5
      error_message = "Service '${each.key}' requests ${each.value.replicas} replicas; the allowed range is 1-5."
    }
    postcondition {
      condition     = length(self.content) > 0
      error_message = "Service '${each.key}' generated an empty file."
    }
  }
}

Why: the precondition fails the plan before anything is built and can see each, while the postcondition validates the outcome and is the only one of the two permitted to reference self.

</details>

Challenge 6 — Drive replacement from an external signal (advanced)

Make local_file.manifest rebuild whenever var.release_id changes, without any of the manifest’s own arguments changing. Then force just local_file.service["web"] to be recreated on the next apply without editing any configuration.

<details> <summary>Solution</summary>

variable "release_id" {
  type    = string
  default = "r1"
}

resource "terraform_data" "release" {
  input            = var.release_id
  triggers_replace = [var.release_id]
}

resource "local_file" "manifest" {
  filename = "${path.module}/out/manifest.txt"
  content  = "manifest\n"

  lifecycle {
    replace_triggered_by = [terraform_data.release]
  }
}

For the one-off rebuild, no configuration change is needed:

terraform apply -replace='local_file.service["web"]'

Why: replace_triggered_by must reference a managed resource, so the variable is wrapped in a terraform_data whose triggers_replace makes it replace on change; -replace is the out-of-band equivalent for a single rebuild, and the keyed address needs single quotes so the shell preserves the inner double quotes.

</details>

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