The Error
Error: Reference to undeclared resource. A dynamic block's "content" must refer to the block label or the "iterator" argument.
You hit this during terraform plan or terraform apply. Terraform flags a specific line inside your dynamic block's content section and refuses to resolve a name โ even though that name looks completely valid to you.
Root Cause
Every dynamic block automatically exposes an iterator object named after the block label. Reference anything else inside content, and Terraform treats it as an undeclared resource โ it won't search locals, variables, or anywhere else.
Here's where it typically goes wrong:
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = rule.value.from_port # ERROR: "rule" is not defined
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
}
The block label is ingress, so the iterator is also ingress. Not rule. Using rule.value.* triggers the error because Terraform scans for a resource named rule and finds nothing.
Two other common triggers: copying a dynamic block between resources without updating iterator references, or renaming a block label while leaving the old iterator name scattered through content.
Fix 1 โ Use the Correct Auto-Generated Iterator Name
The simplest fix: rename your iterator references inside content to match the block label.
# Block label is "ingress" โ iterator is automatically "ingress"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port # Correct
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
ingress.value gives you the current element; ingress.key gives you the map key (or list index).
Fix 2 โ Declare an Explicit Iterator
Prefer a shorter name like rule? Add an iterator argument directly inside the dynamic block. This overrides the default name.
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule # Explicitly name the iterator "rule"
content {
from_port = rule.value.from_port # Now valid
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
}
The iterator argument is a local alias only โ it changes how you reference the current item inside content, but has zero effect on the generated Terraform configuration.
Full Working Example
A complete AWS security group with two dynamic ingress rules, ports 80 and 443:
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{ from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
# Approach 1: use block label as iterator
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Nested Dynamic Blocks
Each nested dynamic block gets its own iterator scope, defaulting to the block label. That works fine โ until two labels share the same name, or you accidentally reference the outer iterator from inside the inner content. Explicit iterator names eliminate the ambiguity entirely:
resource "aws_wafv2_rule_group" "example" {
name = "example"
scope = "REGIONAL"
capacity = 100
dynamic "rule" {
for_each = var.rules
iterator = waf_rule # outer iterator
content {
name = waf_rule.value.name
priority = waf_rule.value.priority
dynamic "statement" {
for_each = waf_rule.value.statements
iterator = stmt # inner iterator
content {
# Use stmt.value here, not rule.value
byte_match_statement {
search_string = stmt.value.search_string
}
}
}
}
}
}
Without iterator = stmt, the inner block defaults to statement as its name. Write rule.value inside it and you silently grab the outer iterator, producing wrong data. Use a third name entirely and you're back to the original undeclared reference error.
Verify the Fix
- Run
terraform validatefirst โ no backend connection required:
terraform validate
# Expected: Success! The configuration is valid.
- Run
terraform planand check that the expanded blocks appear in the output:
terraform plan
# Look for the expanded blocks in the plan output, e.g.:
# + ingress {
# + from_port = 80
# + to_port = 80
# ...
Both passing means the iterator is resolved correctly. Keep in mind that validate only checks syntax and references โ you need plan to confirm the actual expanded values look right.
Prevention
- Start with the block label. When writing a new
dynamicblock, use the auto-generated iterator name first. Only add an explicititeratorwhen the label is ambiguous or conflicts with a nested block. - Rename label and iterator together. Changing a block label? Do a scoped find-replace on the old iterator name inside that block's
contentbefore committing. - Add
terraform validateto CI. It runs in under a second for most modules, needs no backend, and catches reference errors like this before a pull request merges. - Always name inner iterators explicitly. Nested
dynamicblocks with default iterator names are a readability trap. Oneiteratorargument makes the scope unambiguous at a glance.

