Meta-Arguments: count, for_each and depends_on
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Almost every real configuration needs to create "several of the same thing": three replicas of catalog-api, one
container per service, one bucket per environment. HCL has no loop statement, so Terraform solves the problem with
meta-arguments: arguments that are understood by Terraform core and are available on every resource, data source
and module regardless of the provider. This page covers the three that shape the dependency graph and the number of
instances, count, for_each and depends_on, together with provider and providers, and it explains the part
that surprises people the most: how each instance is identified, because that identity decides what Terraform
destroys when a list or map changes. It describes Terraform 1.17 and OpenTofu 1.13.
The page follows Resources and Data Sources in the reading
path, where the lifecycle of a single resource instance is explained, and it prepares
Lifecycle, Validation and Checks (the lifecycle
meta-argument), Modules (where count and for_each repeat a whole module) and
The Dependency Graph and the Plan, which shows how the
dependencies declared here become graph edges. The for expressions and dynamic blocks that often accompany these
meta-arguments are described in Types and Expressions.
All examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket).
They run locally with the kreuzwerker/docker and hashicorp/random providers, so no cloud account is needed.
The meta-arguments at a glance
A meta-argument is an argument that does not belong to the provider’s schema. The provider decides which arguments
a docker_container accepts; Terraform core decides which meta-arguments any block accepts. Because core handles
them, they behave the same for every provider, and a provider can never override them.
| Meta-argument | Available in | Purpose |
|---|---|---|
|
|
Create a number of instances given by a whole number. Each instance is identified by an index and |
|
|
Create one instance per element of a map or a set of strings. Each instance is identified by a key and the |
|
|
Declare a dependency that Terraform cannot infer from references. |
|
|
Choose an alternate (aliased) provider configuration for a single block. |
|
|
Map provider configurations of the calling module to the provider names used inside the child module. |
|
|
Customize how Terraform treats an object: |
Source: Terraform Docs — The count meta-argument, Terraform Docs — The for_each meta-argument, Terraform Docs — The depends_on meta-argument, Terraform Docs — The provider meta-argument and Terraform Docs — The providers meta-argument.
A single block cannot use both count and for_each. In OpenTofu the same restriction extends to the enabled
argument (see [terraform-and-opentofu-differences]). Meta-arguments are not variables: their values are evaluated
while Terraform builds the graph, and that restriction explains most of the limitations described later.
count: a number of identical instances
count takes a whole number and creates that many instances of a resource, data source or module. The instances are
numbered from zero, and inside the block count.index holds the number of the instance being configured.
terraform {
required_version = ">= 1.15"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
}
variable "catalog_api_replicas" {
type = number
description = "Number of catalog-api containers to run."
default = 3
validation {
condition = var.catalog_api_replicas >= 0 && floor(var.catalog_api_replicas) == var.catalog_api_replicas
error_message = "The number of replicas must be a whole number greater than or equal to zero."
}
}
resource "docker_image" "catalog_api" {
name = "nginx:1.27-alpine"
keep_locally = true
}
resource "docker_container" "catalog_api" {
count = var.catalog_api_replicas
name = "catalog-api-${count.index}"
image = docker_image.catalog_api.image_id
ports {
internal = 80
external = 8080 + count.index
}
}
With the default of three replicas the plan creates three containers (catalog-api-0, catalog-api-1 and
catalog-api-2) listening on ports 8080, 8081 and 8082. The block as a whole is addressed as
docker_container.catalog_api, which is now a list of objects, and each instance is addressed with its index:
docker_container.catalog_api[0].
The count object
Inside a block that uses count there is exactly one extra named value: count.index, a zero-based number. It can be
used in any argument of the block, including nested blocks, provisioner blocks and connection blocks (where
self refers to the instance). count.index is not available outside the block, and other blocks reach an instance
only through its address.
Referencing the instances
A block that uses count is a list in expressions, so the usual list syntax applies.
output "catalog_api_names" {
description = "Names of every catalog-api container."
value = docker_container.catalog_api[*].name
}
output "first_catalog_api_name" {
description = "Name of the first catalog-api container."
value = docker_container.catalog_api[0].name
}
output "catalog_api_ports" {
description = "Host ports of every catalog-api container."
value = [for c in docker_container.catalog_api : c.ports[0].external]
}
docker_container.catalog_api[*].name is the splat form of the for expression in the last output. When
catalog_api_replicas is zero the list is empty and both forms return an empty list, but
docker_container.catalog_api[0].name fails because the instance does not exist. That is why the "zero or one"
idiom below always indexes with care.
count as a switch
The most common use of count is not repetition at all but a conditional resource: a value of 1 creates the
resource and 0 removes it.
variable "enable_redis" {
type = bool
description = "Whether the Bookshelf stack runs a Redis cache."
default = true
}
resource "docker_image" "redis" {
count = var.enable_redis ? 1 : 0
name = "redis:7-alpine"
keep_locally = true
}
resource "docker_container" "redis" {
count = var.enable_redis ? 1 : 0
name = "bookshelf-redis"
image = docker_image.redis[0].image_id
}
output "redis_container_id" {
description = "Identifier of the Redis container, or null when Redis is disabled."
value = one(docker_container.redis[*].id)
}
Two details make this idiom comfortable. A resource that is behind count must always be indexed, even when only one
instance can exist (docker_image.redis[0]), and a resource that depends on a conditional one must be conditional
too, otherwise the reference docker_image.redis[0] fails when Redis is disabled. The one() function converts the
list of zero or one elements into either null or the single object, which keeps outputs from exploding. OpenTofu 1.11
added a dedicated enabled argument inside lifecycle for exactly this case (see
[terraform-and-opentofu-differences]).
What count accepts
-
A whole number. A non-integer, a negative value or a non-number is an error. A numeric string such as "3" is converted to a number when it is a valid numeric string.
-
A value that is known during planning. A literal, a variable, a local or a function of those is fine. A value that comes from another resource’s computed attribute is not (see Unknown values: known only after apply).
-
A value that is not sensitive in the sense that matters for
for_eachbelow: acountthat depends on a sensitive variable is accepted, because only a number is shown, but anything that derives the number from a sensitive collection leaks its length in the plan. -
0, which removes every instance and keeps the block in the configuration.
count = length(var.services) is a very common way to repeat a block once per element of a list, and it is exactly
where count becomes dangerous; the next sections explain why.
for_each: one instance per key
for_each takes a map or a set of strings and creates one instance per element. Each instance is identified by a
key: the map key, or the string itself for a set. Inside the block the each object exposes the current element.
variable "services" {
type = map(object({
image = string
internal_port = number
external_port = number
}))
description = "Bookshelf application services, keyed by service name."
default = {
catalog-api = {
image = "nginx:1.27-alpine"
internal_port = 80
external_port = 8081
}
reviews-api = {
image = "nginx:1.27-alpine"
internal_port = 80
external_port = 8082
}
web = {
image = "nginx:1.27-alpine"
internal_port = 80
external_port = 8080
}
}
}
resource "docker_image" "service" {
for_each = var.services
name = each.value.image
keep_locally = true
}
resource "docker_container" "service" {
for_each = var.services
name = "bookshelf-${each.key}"
image = docker_image.service[each.key].image_id
ports {
internal = each.value.internal_port
external = each.value.external_port
}
}
The instances are addressed by key, not by position: docker_container.service["catalog-api"],
docker_container.service["reviews-api"] and docker_container.service["web"]. Adding a fourth entry to the map adds
one container and touches nothing else. Removing reviews-api destroys exactly that container, whatever its position
in the file.
The each object
| Reference | Value |
|---|---|
|
The map key, or the set member, of the instance being configured. |
|
The map value of the instance. For a set of strings it is equal to |
each is only defined inside the block that declares for_each. If a nested dynamic block needs the outer
each, capture it first in a locals value or give the dynamic block an explicit iterator, because dynamic
blocks bring their own iterator named after the block label. Within provisioner and connection blocks self
refers to the instance and not to the whole resource.
Sets of strings and toset
A set is the natural input when there is nothing to say about an element except its name, such as a list of bucket
or queue names. HCL has no set literal, so a list is converted with toset().
variable "service_names" {
type = list(string)
description = "Names of the Bookshelf services that need a data volume."
default = ["catalog-api", "reviews-api", "web"]
}
resource "docker_volume" "data" {
for_each = toset(var.service_names)
name = "bookshelf-${each.key}-data"
}
for_each does not convert a list or a tuple implicitly. Passing var.service_names directly is an error that says
the argument must be a map or set of strings. toset() discards both duplicates and ordering, which is precisely the
behaviour you want for identity: reordering the list does not change a single address. A variable declared as
set(string) avoids the conversion altogether. When each instance needs different arguments, use a map (as in the
previous example) and carry the per-instance data in each.value.
Deriving a map with a for expression
Often the source data is a list of objects. A for expression turns it into a map whose keys are stable names.
locals {
databases = [
{ name = "catalog", owner = "catalog_api" },
{ name = "reviews", owner = "reviews_api" },
]
databases_by_name = { for d in local.databases : d.name => d }
}
resource "random_password" "database" {
for_each = local.databases_by_name
length = 24
special = false
}
output "database_owners" {
description = "Database owner of every Bookshelf database, keyed by database name."
value = { for name, db in local.databases_by_name : name => db.owner }
}
The key must be unique: the for expression above fails if two objects share a name, which is a useful validation
in disguise. Choose keys that describe the thing and do not change (a service name, a region, an environment), not
keys built from an attribute that may be edited later, because the key is the identity of the instance.
Chaining for_each
A resource that uses for_each over another resource that uses for_each inherits its keys. The container resource
above reads docker_image.service[each.key]. The same idea works with the whole resource, since a block with
for_each is a map of instances:
resource "docker_container" "sidecar" {
for_each = docker_container.service
name = "${each.value.name}-sidecar"
image = docker_image.service[each.key].image_id
}
Chaining is safe because the keys of docker_container.service are known at plan time, and each instance of the
sidecar lives and dies with its counterpart. Only the keys have to be known before apply; each.value may contain
attributes that are known only after apply, such as each.value.id. This distinction is the root of the
workarounds in Unknown values: known only after apply.
Nested collections with flatten
Two levels of repetition, such as "every database of every service", cannot be expressed with a single for_each
over a nested structure. Flatten the combinations into one list of objects, give each one a composite key, and iterate
over the resulting map.
locals {
service_databases = {
catalog-api = ["catalog", "catalog_audit"]
reviews-api = ["reviews"]
}
database_grants = flatten([
for service, databases in local.service_databases : [
for database in databases : {
key = "${service}.${database}"
service = service
database = database
}
]
])
}
resource "terraform_data" "grant" {
for_each = { for g in local.database_grants : g.key => g }
input = "GRANT ALL ON DATABASE ${each.value.database} TO ${each.value.service}"
}
The instances are terraform_data.grant["catalog-api.catalog"], terraform_data.grant["catalog-api.catalog_audit"]
and terraform_data.grant["reviews-api.reviews"]. For a full cross product of two independent collections
setproduct() returns every pair, and the same composite key technique applies. The functions are introduced in
Functions.
for_each on data sources and modules
for_each and count are available on data sources exactly as on resources, and on module blocks (see
Meta-arguments on modules). A data source with for_each is read once per key, which is how a configuration looks
up many objects with one block.
data "docker_network" "lookup" {
for_each = toset(["bridge", "host"])
name = each.key
}
output "network_ids" {
description = "Identifier of each pre-existing Docker network that was looked up."
value = { for name, n in data.docker_network.lookup : name => n.id }
}
Instance addresses
An address names an object in the configuration and in state. Addresses appear in the plan, in error messages, in
the output of terraform state list, and as arguments to commands such as -target, -replace, terraform state
mv and terraform import. The rules are simple, and they are the key to understanding everything else on this page.
| Block | Address of the block and of its instances |
|---|---|
Without |
|
With |
|
With |
|
Data source |
|
Module |
|
Resource inside a module instance |
|
Source: Terraform Docs — Resource addressing.
The index is part of the identity. docker_container.catalog_api[1] is a different object from
docker_container.catalog_api[2] as far as state is concerned, even when they are configured almost identically.
This is the whole story behind the next section.
Reading addresses in state
terraform state list prints one address per line, which makes it the quickest way to see which instances Terraform
believes exist. The output below shows the Bookshelf stack of this page after applying the count and for_each
examples.
$ terraform state list
data.docker_network.lookup["bridge"]
data.docker_network.lookup["host"]
docker_container.catalog_api[0]
docker_container.catalog_api[1]
docker_container.catalog_api[2]
docker_container.redis[0]
docker_container.service["catalog-api"]
docker_container.service["reviews-api"]
docker_container.service["web"]
docker_image.catalog_api
docker_image.redis[0]
docker_image.service["catalog-api"]
docker_image.service["reviews-api"]
docker_image.service["web"]
$ terraform state list 'docker_container.service'
docker_container.service["catalog-api"]
docker_container.service["reviews-api"]
docker_container.service["web"]
$ terraform state show 'docker_container.service["web"]'
# docker_container.service["web"]:
resource "docker_container" "service" {
id = "0123456789ab..."
name = "bookshelf-web"
...
}
Quoting matters in a shell. The double quotes inside a for_each address are consumed by the shell unless the whole
address is in single quotes, as above (or each inner quote is escaped with a backslash). In PowerShell and cmd.exe
the rules differ again, and the official documentation lists the variants. A bare address such as
docker_container.service selects every instance of the block.
Targeting instances
The -target option of plan, apply and destroy restricts an operation to one address and the objects it depends
on. The address may be a whole block or one instance.
$ terraform plan -target='docker_container.service["web"]'
$ terraform apply -target=docker_container.catalog_api
$ terraform apply -target='module.network["staging"]'
-target is meant for exceptional situations: recovering from an error, or working around an unknown value (see
below). Terraform prints a warning after a targeted plan because the rest of the configuration was ignored, so the
state can legitimately differ from the configuration afterwards. A routine workflow that depends on -target is a
sign that the configuration should be split into smaller root modules. OpenTofu also offers -exclude, the inverse
option, which plans everything except the given address and its dependents. The planning options are described in
The Dependency Graph and the Plan and the commands in
CLI Essentials.
Why for_each is safer than count
A count instance is identified by its position. As long as the list only grows at the end, positions are stable.
The moment an element is removed from the middle, every element after it moves one position to the left, and
Terraform sees that as a series of changes rather than as the removal of one element.
variable "queue_names" {
type = list(string)
default = ["catalog-events", "reviews-events", "web-events"]
}
resource "terraform_data" "queue_by_count" {
count = length(var.queue_names)
input = var.queue_names[count.index]
}
resource "terraform_data" "queue_by_key" {
for_each = toset(var.queue_names)
input = each.key
}
Suppose reviews-events is deleted from the list. The diagram shows what happens in each variant.
With count the plan shows an in-place update (or, for arguments that force replacement, a replacement) of
queue_by_count[1], which now has to become the web-events queue, and a destruction of queue_by_count[2]. Nothing
in the plan says "delete reviews-events": Terraform has no way to know that the removed element was the one in the
middle. For a message queue the consequence might be a few seconds of renaming; for a database, a disk or a
Kubernetes namespace it can be data loss. With for_each the plan is exactly what a human would expect: one
destruction, at queue_by_key["reviews-events"], and no other change.
$ terraform plan
...
# terraform_data.queue_by_count[1] must be replaced
-/+ resource "terraform_data" "queue_by_count" {
~ id = "..." -> (known after apply)
~ input = "reviews-events" -> "web-events" # forces replacement
}
# terraform_data.queue_by_count[2] will be destroyed
# (because index [2] is out of range for count)
- resource "terraform_data" "queue_by_count" {
- input = "web-events" -> null
}
# terraform_data.queue_by_key["reviews-events"] will be destroyed
# (because key ["reviews-events"] is not in for_each map)
- resource "terraform_data" "queue_by_key" {
- input = "reviews-events" -> null
}
Plan: 1 to add, 1 to destroy ... (abbreviated)
The comments because index [2] is out of range for count and because key ["reviews-events"] is not in for_each map
are what Terraform really prints next to the destroy action, and they are worth learning to read in a plan: they
tell you which of the two mechanisms is responsible.
A rule of thumb
| Question | count |
for_each |
|---|---|---|
What identifies an instance? |
Its position in a sequence. |
A key chosen by you. |
Removing an element from the middle |
Shifts every later instance and can update or replace them. |
Destroys only the removed instance. |
Reordering the input |
Reorders the instances and can change them. |
No effect on the instances. |
Instances differ in their arguments |
Awkward: every difference is derived from |
Natural: the map value carries the differences. |
Zero or one instance (a switch) |
|
|
Value required to be known during plan |
A whole number. |
Every key of the map or element of the set. |
Typical use |
A fixed number of identical, interchangeable replicas; a conditional resource. |
Anything with a name: services, buckets, users, regions, environments. |
The documentation itself gives the same advice: use count when the instances are nearly identical, and use
for_each when some of their arguments must have distinct values that cannot be derived from an integer. In practice,
default to for_each whenever the instances have names, and keep count for the on/off switch and for truly
interchangeable replicas whose number only ever grows or shrinks at the end.
Moving from count to for_each
Changing an existing resource from count to for_each changes every address, so a plain edit makes Terraform plan to
destroy all the old instances and create all the new ones. The state can be rewritten without touching the
infrastructure by declaring a moved block for each instance.
moved {
from = terraform_data.queue_by_count[0]
to = terraform_data.queue_by_key["catalog-events"]
}
moved {
from = terraform_data.queue_by_count[1]
to = terraform_data.queue_by_key["reviews-events"]
}
moved {
from = terraform_data.queue_by_count[2]
to = terraform_data.queue_by_key["web-events"]
}
The plan then reports has moved to lines instead of destroy and create actions. The older command-line equivalent is
terraform state mv with the same addresses, which edits the state immediately and cannot be reviewed in a pull
request. Refactoring, including the removed block, is the subject of
Refactoring: moved and removed.
Unknown values: known only after apply
Both count and for_each decide how many instances exist, and Terraform needs that decision before it can build
the rest of the graph, because every instance is a separate node and every node must be planned. So the value of
count and the keys of for_each must be known during planning. Values that are "known after apply", such as the id
of a resource that does not exist yet, cannot be used.
The errors
When the rule is violated, the plan stops with an error that names the argument and says that it depends on a value that cannot be determined until apply. The text varies a little between versions; these are the usual shapes.
$ terraform plan
╷
│ Error: Invalid for_each argument
│
│ on main.tf line 23, in resource "docker_container" "per_network":
│ 23: for_each = toset(docker_network.created[*].id)
│ ├────────────────
│ │ docker_network.created is a list of object, known only after apply
│
│ The "for_each" set includes values derived from resource attributes that
│ cannot be determined until apply, and so Terraform cannot determine the
│ full set of keys that will identify the instances of this resource.
│
│ When working with unknown values in for_each, it's better to use a map
│ value where the keys are defined statically in your configuration and where
│ only the values contain apply-time results.
│
│ Alternatively, you could use the -target planning option to first apply only
│ the resources that the for_each value depends on, and then apply a second
│ time to fully converge.
╵
For count the message is Invalid count argument, with the sentence The "count" value depends on resource
attributes that cannot be determined until apply, so Terraform cannot predict how many instances will be created. To
work around this, use the -target argument to first apply only the resources that the count depends on. The wording
above is reproduced from the behaviour of recent releases in abbreviated form; the documentation pages describe the
condition ("dependencies that cannot be determined before apply") without quoting the message, so treat the exact
text as indicative.
Why the keys, and only the keys
The distinction that resolves most cases is between the keys of a collection and its values. A for_each map may
contain unknown values: they just have to be associated with keys that are known. Compare the two configurations
below.
resource "docker_network" "created" {
for_each = toset(["frontend", "backend"])
name = "bookshelf-${each.key}"
}
# Fails: the set of keys is built from ids that only exist after apply.
resource "terraform_data" "bad" {
for_each = toset([for n in docker_network.created : n.id])
input = each.key
}
# Works: the keys come from the network keys, the ids stay in the values.
resource "terraform_data" "good" {
for_each = { for key, n in docker_network.created : key => n.id }
input = each.value
}
In good, Terraform knows during planning that two instances exist, terraform_data.good["frontend"] and
terraform_data.good["backend"], because the keys of docker_network.created are the static strings from the
toset() literal. The input is "known after apply", and that is fine.
Workarounds
| Situation | Workaround |
|---|---|
The keys come from an attribute of another resource |
Build the map from keys that are static (names, indexes of a static list, the keys of another |
The number of instances is derived from a collection that another resource produces |
Derive the number from the input of that resource (the variable or local that feeds it) instead of from its
output. If the other resource has |
A value really is only available after another object exists |
Split the work in two: apply the dependency first with |
The dependency is a different component of the platform (a network, a cluster) |
Put it in a separate root module and read it back with a data source or |
The value comes from |
Impure functions are evaluated late and cannot supply keys. Use a stable name, or |
The collection contains sensitive values |
Sensitive input variables, outputs and attributes are rejected because keys appear in the plan output. Build the
keys from non-sensitive data: |
A frequent trap is count = length(aws_subnet.private[*].id) or for_each = data.some_thing.all.ids over a data
source whose result depends on a resource in the same configuration: as soon as the data source’s read is deferred
until apply, every consumer fails with the error above. The remedy is the same: derive the repetition from inputs the
configuration already knows. Data sources whose configuration is fully known are read during planning (see
Resources and Data Sources), and a data source is deferred
only when its arguments are unknown or when depends_on names a resource with pending changes.
Sensitive values
Keys appear in the plan, in state and in the UI, so Terraform refuses them if they are sensitive. A sensitive map can
still drive for_each if the keys are made non-sensitive explicitly.
variable "service_tokens" {
type = map(string)
sensitive = true
}
resource "terraform_data" "token_holder" {
for_each = toset(nonsensitive(keys(var.service_tokens)))
input = sensitive(var.service_tokens[each.key])
}
Calling nonsensitive() is a promise that the names themselves are not secret. Do not use it on values whose
disclosure matters. OpenTofu 1.8 improved the error message for a for_each over a sensitive value of an unsuitable
type.
Deferred actions (experimental)
Terraform 1.17 contains an experimental "deferred actions" feature, available only in alpha builds and enabled with
terraform plan -allow-deferral. It lets count and for_each have unknown values by deferring the dependent
objects to a later plan, and lets providers react to unknown values. It is not in stable releases, so stable
configurations must still follow the rules above.
Source: Terraform CHANGELOG (v1.17).
depends_on and hidden dependencies
Terraform orders operations by reading the references between blocks. If a container reads
docker_image.catalog_api.image_id, the image is built first. Those are implicit dependencies, they are the
overwhelming majority, and they are the reason a configuration does not have to say what runs first. The full
mechanics are in The Dependency Graph and the Plan.
A hidden dependency is a relationship that exists in the real world but not in the arguments. A classic example:
catalog-api reads a table that only exists after a schema migration, but nothing in its arguments mentions the
migration. The depends_on meta-argument adds an explicit edge for such cases.
resource "docker_network" "bookshelf" {
name = "bookshelf"
}
resource "docker_container" "postgres" {
name = "bookshelf-postgres"
image = "postgres:16-alpine"
env = ["POSTGRES_PASSWORD=${random_password.database["catalog"].result}"]
networks_advanced {
name = docker_network.bookshelf.name
}
}
# The migration job needs the database to be running, but it reaches it by network name,
# so no argument references the container.
resource "terraform_data" "schema_migration" {
input = "migrate-catalog-schema-v1"
depends_on = [docker_container.postgres]
provisioner "local-exec" {
command = "echo Running migrations against bookshelf-postgres"
}
}
The rules
-
The value is a list of references to resources, data sources or child modules in the same module. A reference to a whole block with
countorfor_each(docker_container.service, without an index) depends on all its instances. Arbitrary expressions, function calls and references to variables or locals are not allowed, because the dependencies must be known before expressions can be evaluated. -
Terraform completes all actions on the dependency, including reads of data sources, before acting on the dependent object. When the dependency is a module, it affects every resource and data source in that module.
-
The meta-argument is available in
resource,data,ephemeral,module,outputandcheckblocks, and in Stackscomponentblocks. -
Cycles are errors: if
adepends onbandbdepends ona, Terraform reports the cycle at plan time and lists the nodes involved.
Why it is a last resort
The documentation calls depends_on a last resort for a reason. With a reference such as
docker_image.catalog_api.image_id, Terraform can tell that the dependent object only needs to change when that one
attribute changes. With depends_on, the dependent object must be treated as "possibly affected by anything" in the
dependency, so the plan tends to be more conservative: more values show as "known after apply", and more objects are
planned for update or replacement than strictly necessary. The effect is largest with a whole module as dependency.
Prefer, in this order:
-
a real reference (use the dependency’s output in an argument, even a dummy one);
-
a data source that reads the dependency;
-
depends_onwith a comment explaining why the dependency is hidden.
resource "docker_container" "web" {
name = "bookshelf-web"
image = "nginx:1.27-alpine"
# Hidden dependency: web proxies to both APIs by DNS name on the shared network, so the
# containers must exist before web starts. No argument of web reads them.
depends_on = [
docker_container.service,
]
}
Always write a comment next to a depends_on: it is the only record of why the edge exists, and without it the next
maintainer cannot tell whether it can be deleted.
depends_on and data sources
A data source is normally read during the plan when its arguments are known, so that the plan can use the result.
If a data source declares depends_on on a resource that has pending changes, the read is deferred until the apply,
because the dependency must complete first, and every value derived from the data source becomes "known after
apply". That can in turn trigger the unknown-value errors from the previous section when the result is used in
count or for_each. Therefore, give a data source a depends_on only when it genuinely needs to see changes made
during the same run, and prefer arguments that reference the dependency, so that Terraform knows when the read is
safe.
The same meta-argument is how a check waits for infrastructure to exist. A data block nested in a check block
may use depends_on so the assertion is evaluated after another resource has been created, and until that resource
exists Terraform shows a "known after apply" warning rather than a failure.
check "web_is_healthy" {
data "http" "web" {
url = "http://localhost:8080/"
depends_on = [docker_container.web]
}
assert {
condition = data.http.web.status_code == 200
error_message = "The web container did not answer with HTTP 200."
}
}
The http data source belongs to the hashicorp/http provider, declared in required_providers. If the check
references the resource directly in its arguments, every change to the resource produces the warning, which makes the
check noisy; depends_on is the better choice when the data block does not otherwise reference the resource.
Checks are described in Lifecycle, Validation and Checks.
depends_on in outputs
An output rarely needs depends_on, because it normally references the values it exposes. When an output must wait
for something that is not part of its value (for example, a module output that is only meaningful once a policy
attachment has completed), add depends_on and say why in a comment. The output page covers this
in Variables, Outputs and Locals.
depends_on and replacement
A resource that depends on another one is not necessarily replaced when the other one is replaced. Dependencies
control ordering, and the changes are driven by differences in arguments. If a container must be recreated when a
configuration file changes, a reference alone is not enough; the replace_triggered_by lifecycle argument expresses
that relationship explicitly, and it is described in
Lifecycle, Validation and Checks.
provider and providers
A resource uses the default configuration of the provider named by the first word of its type: docker_container
uses the default docker provider. A provider block with an alias creates an alternate configuration, and the
provider meta-argument selects it for a single block. A module receives configurations through providers.
provider on resources and data sources
terraform {
required_version = ">= 1.15"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
}
provider "docker" {
host = "unix:///var/run/docker.sock"
}
provider "docker" {
alias = "remote"
host = "ssh://deploy@staging.example.internal"
}
resource "docker_image" "local" {
name = "nginx:1.27-alpine"
}
resource "docker_image" "staging" {
provider = docker.remote
name = "nginx:1.27-alpine"
}
The value is a reference of the form PROVIDER.ALIAS without quotes. It must be a static reference: Terraform needs it
while building the graph, so arbitrary expressions, variables and conditionals are not permitted. This is why a single
resource with for_each cannot pick a different provider per key in Terraform. Each resource implicitly depends on its
provider, which is fully configured before any action on the resource runs. The provider argument is also valid in
data, ephemeral, import, check and list blocks; a list block does not get the implicit default and needs
it explicitly.
Everything about provider configuration itself, aliases, authentication and multi-region setups is in Providers.
providers on modules
A child module inherits every default (non-aliased) provider configuration of its caller. It never inherits aliased
configurations automatically. The providers argument of a module block changes this: it maps each provider name
used inside the module to a configuration of the caller.
module "service_stack_local" {
source = "./modules/service-stack"
# No providers argument: the child uses the default docker configuration.
}
module "service_stack_staging" {
source = "./modules/service-stack"
providers = {
docker = docker.remote
}
}
Specifying providers cancels the default behavior: the child only receives the configurations listed. When a module
needs two configurations of the same provider (for example a source and a destination region), it declares them with
configuration_aliases in its own required_providers, and the caller maps both.
# modules/replicated-bucket/versions.tf
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
configuration_aliases = [docker.source, docker.destination]
}
}
}
# root module
module "covers_sync" {
source = "./modules/replicated-bucket"
providers = {
docker.source = docker
docker.destination = docker.remote
}
}
Two rules from modules matter here. Child modules should not contain their own provider blocks: a module with its
own provider configuration cannot be used with count, for_each or depends_on, and cannot be removed cleanly
because the provider configuration disappears together with the module. Keep provider blocks in the root module and
pass them down. In Terraform the providers map is static, like provider; you cannot compute it.
Meta-arguments on modules
The module block accepts count, for_each, depends_on and providers in addition to its own source and
version. They work like their resource counterparts, applied to an entire subtree of resources. The module system is described in
Modules.
count and for_each on a module
module "environment" {
source = "./modules/bookshelf-environment"
for_each = {
dev = { replicas = 1 }
staging = { replicas = 2 }
}
name = each.key
replicas = each.value.replicas
}
output "environment_urls" {
description = "URL of every environment."
value = { for name, m in module.environment : name => m.url }
}
Every resource inside the module gets the module instance as a prefix: module.environment["dev"].docker_network.main
and module.environment["staging"].docker_network.main. Referencing outputs works as with a resource:
module.environment["dev"].url for one instance, or a for expression over module.environment for all of them. The
same rules apply as on resources: count and for_each cannot be used together, must be known during planning, and
the instance identity is the index or key. Removing one key destroys one environment.
depends_on on a module
A module-level depends_on makes every resource and data source inside the module wait for the dependency. It is
rarely needed and expensive, for the reasons given above, because any change to the dependency can cascade to every
object in the module.
module "web_frontend" {
source = "./modules/web"
image = "nginx:1.27-alpine"
# Hidden dependency: the web module reaches the APIs by name through a shared network
# that the environment module owns. Nothing in the inputs refers to the APIs.
depends_on = [module.environment]
}
A cheaper alternative is to pass something from the dependency as an input, such as the network name, and use it in the child module. That creates a precise reference instead of a blanket ordering.
Restrictions that surprise people
-
A module that contains
providerblocks cannot usecount,for_eachordepends_on. -
The
sourceandversionarguments of amoduleblock must be known whenterraform initruns, socountandfor_eachcannot vary the source per instance. Since Terraform 1.15 const variables and locals can appear insourceandversion; OpenTofu has allowed statically evaluated variables there since 1.8. See Variables, Outputs and Locals. -
Module outputs may be unknown at plan time, so they cannot drive another
countorfor_eachunless the keys are independent of apply-time values. -
A resource inside a repeated module cannot be addressed with
-targetwithout the full module instance in the address:module.environment["dev"].docker_network.main.
The lifecycle meta-argument
lifecycle is the sixth meta-argument and the one that changes how Terraform treats a resource rather than how many
instances exist: create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, precondition
and postcondition. It interacts with this page in two ways. prevent_destroy blocks the destroy action that a
removed for_each key or a shrinking count would otherwise cause, and replace_triggered_by lets one instance
follow another. Everything else is in
Lifecycle, Validation and Checks.
Terraform and OpenTofu differences
The meta-arguments are the same in both tools, and the examples above run unchanged with tofu. OpenTofu adds a few
features around them.
| Topic | Terraform | OpenTofu |
|---|---|---|
|
As described above. |
The same semantics, documented on its own pages. |
Zero-or-one instance |
|
Same, and since 1.11 |
|
Not available. A provider configuration is a single instance, and |
Since 1.9, an aliased provider block accepts |
Variables and locals in module sources and backends |
Const variables and locals in module |
Statically evaluated variables and locals in module sources and backend configuration since 1.8. |
Targeting |
|
|
Unknown |
An error; the experimental deferred actions ( |
An error with the same remedies (use static keys, or |
Source: OpenTofu Docs — The for_each meta-argument, OpenTofu Docs — The enabled meta-argument, OpenTofu Docs — Provider Configuration and OpenTofu Docs — The depends_on meta-argument.
for_each in OpenTofu provider blocks
terraform {
required_version = ">= 1.9.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
}
variable "hosts" {
type = map(string)
description = "Docker daemon address of each Bookshelf host, keyed by environment."
default = {
dev = "unix:///var/run/docker.sock"
staging = "ssh://deploy@staging.example.internal"
}
}
provider "docker" {
alias = "by_env"
for_each = var.hosts
host = each.value
}
resource "docker_image" "nginx" {
for_each = var.hosts
provider = docker.by_env[each.key]
name = "nginx:1.27-alpine"
}
The provider reference is static, but the key expression inside the square brackets may be dynamic. All instances of
one resource must bind to the same provider block, though each may use a different instance of it. The provider’s
for_each should be a superset of the resource’s: removing a provider instance that still manages resources in state
is an error, so remove the resources first (for example with a targeted destroy), or keep the provider’s collection
larger and filter the resource’s collection instead. A child module receives such a provider through its providers
argument, and declares the aliases it expects with configuration_aliases.
What changed recently
| Version | Change |
|---|---|
Terraform 0.12 and 0.13 |
|
OpenTofu 1.8 |
Variables and locals in module sources and backend configuration, evaluated statically. A better error message for
|
OpenTofu 1.9 |
|
Terraform 1.15 (2026-04-29) |
Variables and locals are allowed in module |
OpenTofu 1.11 |
The |
Terraform 1.17 (2026-10-21) |
The experimental "deferred actions" feature (alpha builds only, |
Where the books are outdated or wrong
The reference books for this section present count and for_each mostly as tutorials, and a few points need
updating.
-
They teach
count = length(var.list)withcount.indexas the general way to repeat resources, and mention in a sentence that removing an element reshuffles the rest. In practice that sentence deserves a heading: it is the deciding argument forfor_eachkeyed by a stable name whenever instances have identities. -
They describe the limitation as "count and for_each cannot depend on computed values" without the key versus value distinction. Since the keys alone have to be known, a map with static keys and apply-time values is the standard way around it, not a two-step
-targetapply. -
Older material predates
for_eachon modules (Terraform 0.13) and treats module repetition as impossible, and it predatesmovedblocks (1.1), so a switch fromcounttofor_eachis shown with manualterraform state mvcommands. Amovedblock gives the same result and is reviewable. -
One book states that Terraform has no built-in mechanism for dependencies between modules. That is wrong: since Terraform 0.13 a
moduleblock acceptsdepends_on, which makes every object in the module wait for the listed resources or modules (see thedepends_onsection above). Implicit dependencies through module outputs remain the better choice when a value is passed anyway. -
terraform taint, often used together withcountexamples to force recreation of one instance, is deprecated in favour ofterraform apply -replace=ADDRESS; the address of an instance is exactly the one printed byterraform state list.
On any discrepancy follow the official documentation for the version you run.
Pitfalls
-
Using
count = length(var.names)and removing an element from the middle: the plan shows in-place changes and replacements for every later instance and a destruction at the end. Usefor_eachover a map or a set. -
Using a key that comes from an editable attribute (a display name, a computed id). Changing the attribute changes the key, which destroys and recreates the instance. Use a stable identifier and keep the editable data in the value.
-
Passing a list to
for_eachwithouttoset(), or a map of lists where a map of objects was meant. -
Building keys from resource attributes that are known only after apply, and "fixing" the error with a permanent
-targetworkflow instead of restructuring the keys. -
Sprinkling
depends_onon every resource "to be safe". It makes plans conservative and hides the real relationships; use it only for hidden dependencies, and comment each one. -
A
depends_onon a data source that causes its read to be deferred until apply, which makes its results unknown forcountorfor_each. -
Using
depends_onon a module that contains many resources: every resource waits for the dependency, and any change can cascade. Pass a value from the dependency as an input instead. -
Putting
providerblocks inside child modules and then trying to usecount,for_eachordepends_onon the module call, or removing the module. -
Forgetting that
docker_container.catalog_api[0]is not valid for a block withfor_each, and thatdocker_container.service["web"]is not valid for a block withcount. -
Forgetting to quote an instance address in a shell, so
["web"]loses its quotes and the target is not found. -
Referencing a conditional resource (
count = 0) from another resource without handling the empty list.