
August 21, 2026
12 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Repeating identical nested blocks in HashiCorp Configuration Language (HCL) creates fragile, hard-to-maintain infrastructure code that breaks under change. Terraform dynamic blocks in practice solve this by generating repetitive nested structures like ingress, tag, or setting from a single data source, keeping your modules DRY without sacrificing readability. This guide covers the exact syntax, nesting strategies, and validation patterns I use to ship reliable infrastructure for clients ranging from Nepal legal-tech portals to global SaaS platforms.
dynamic keyword and an iterator variable. They replace copy-pasted ingress, tag, or setting blocks with a single declarative loop, reducing drift and making module interfaces cleaner when combined with for_each and validation.How do Terraform dynamic blocks work compared to for_each?
A common mistake is conflating resource-level repetition with block-level generation. Understanding this distinction prevents architectural errors that surface only during production upgrades. When building infrastructure for projects like Laravel applications deployed on AWS, mixing these concepts leads to state corruption or unintended resource replacement.
The for_each meta-argument creates multiple instances of a resource. Each key in your map or set produces a distinct object in the Terraform state file. Removing a key destroys that specific resource instance. This is ideal for provisioning three EC2 instances or five S3 buckets from a variable list.
The dynamic block generates multiple nested configuration blocks inside a single resource instance. The parent resource remains one state object. Only its internal configuration changes. This is required for resources like aws_security_group where ingress and egress are nested blocks, not standalone resources. You cannot use for_each directly on an ingress block; you must wrap it in dynamic "ingress".
| Criteria | for_each | dynamic block |
|---|---|---|
| Scope | Resource-level multiplication | Block-level generation within one resource |
| State impact | Creates/removes entire resource instances | Modifies configuration of existing resource |
| Syntax location | Top-level meta-argument on resource/data/module | Nested inside resource body replacing static block |
| Iterator access | each.key and each.value | Custom label (default: block name) with .key/.value |
| Best for | Multiple similar resources (instances, buckets, users) | Repeated nested configs (rules, tags, settings, permissions) |
| Drift risk | Moderate — key changes cause destroy/create | Lower — config-only updates, no resource replacement |
In my experience working on production Laravel applications hosted on AWS, teams often start with static blocks, then attempt for_each on nested structures, hit syntax errors, and finally discover dynamic. Skipping straight to dynamic blocks when you need repeated nested configuration saves hours of debugging and prevents accidental resource destruction during refactors.
How do you write a basic Terraform dynamic block correctly?
The syntax is precise and unforgiving. A missing content block or incorrect iterator reference causes plan-time failures. Here is the canonical pattern for an AWS security group with variable ingress rules, tested with Terraform 1.9+ and AWS provider 5.x in 2026:
<!-- main.tf -->
variable "ingress_rules" {
description = "List of ingress rules for the security group"
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
description = optional(string, "")
}))
default = [
{ from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTPS" },
{ from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTP" },
]
}
resource "aws_security_group" "app" {
name = "app-sg"
description = "Application security group"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule
content {
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
description = rule.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
} Three elements are mandatory inside every dynamic block:
- The label (
"ingress") must exactly match the nested block name defined in the provider schema. Misspelling it causes an "Unsupported block type" error. - The
for_eachargument accepts a list, map, or set. For lists, the iterator’s.keyis the numeric index; for maps, it is the map key. Always prefer maps with meaningful keys when order independence matters. - The
contentblock contains the actual nested block body. References useiterator_name.valueanditerator_name.key. If you omit theiteratorargument, the default iterator name is the block label itself (e.g.,ingress.value).
I always specify the iterator explicitly even when the default would work. On a real client project involving a legal-tech portal with complex network ACLs, a junior engineer accidentally used ingress.value inside a nested dynamic "ingress" that was itself inside another dynamic context. The shadowing caused silent misconfiguration. Explicit names like rule, tag_item, or setting_entry eliminate this class of bug entirely.
Validation belongs on the variable, not inside the dynamic block. Terraform 1.9+ supports validation blocks on variables with complex conditions. Catch invalid port ranges or empty CIDR lists before terraform plan ever reaches the provider:
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
description = optional(string, "")
}))
validation {
condition = alltrue([for r in var.ingress_rules : r.from_port >= 0 && r.to_port <= 65535 && r.from_port <= r.to_port])
error_message = "All ingress rules must have valid port ranges (0-65535, from <= to)."
}
validation {
condition = alltrue([for r in var.ingress_rules : length(r.cidr_blocks) > 0])
error_message = "Each ingress rule must specify at least one CIDR block."
}
} This pattern keeps the dynamic block purely generative. Business logic and constraints live where they belong: at the interface boundary. On projects where multiple teams consume shared modules, this separation prevents downstream users from triggering cryptic provider errors due to malformed input.
How do you handle nested dynamic blocks and complex iterators?
Some provider resources require dynamic blocks inside other dynamic blocks. AWS WAFv2 rule groups, Azure Policy definitions, and Kubernetes CRDs frequently demand this. The key rule: every nested dynamic must declare its own unique iterator. Reusing the parent iterator name shadows the outer scope and produces incorrect values without warning.
<!-- wafv2_rule_group.tf -->
variable "waf_rules" {
type = map(object({
priority = number
action = string
match_statements = list(object({
field = string
operator = string
values = list(string)
transformations = optional(list(string), [])
}))
}))
}
resource "aws_wafv2_rule_group" "app" {
name = "app-rules"
scope = "REGIONAL"
capacity = 100
dynamic "rule" {
for_each = var.waf_rules
iterator = r
content {
name = r.key
priority = r.value.priority
action = r.value.action == "allow" ? { allow {} } : { block {} }
statement {
or_statement {
dynamic "statement" {
for_each = r.value.match_statements
iterator = stmt # UNIQUE name — never reuse 'r'
content {
byte_match_statement {
field_to_match {
uri_path {}
}
positional_constraint = stmt.value.operator
search_string = join("", stmt.value.values)
text_transformation {
priority = 0
type = coalesce(one(stmt.value.transformations), "NONE")
}
}
}
}
}
}
}
}
} Notice the inner iterator is named stmt, not r or rule. Inside the inner content block, r.value.match_statements still refers to the outer rule’s data because r remains in scope. But stmt.value accesses the current match statement. If both were named r, the inner reference would silently resolve to the match statement object, breaking the outer priority lookup.
Filtering collections before iteration avoids conditional logic inside content. Use for expressions in the for_each argument to pre-filter:
dynamic "ingress" {
for_each = {
for idx, rule in var.ingress_rules : idx => rule
if rule.enabled != false && length(rule.cidr_blocks) > 0
}
iterator = rule
content {
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
} This keeps the content block free of ternaries and can() checks. Filtering at the for_each level also makes terraform plan output cleaner — disabled rules simply don’t appear in the diff rather than showing as null-valued attributes. For teams managing infrastructure across Nepal and international regions where compliance rules differ, this filtering pattern lets a single module adapt to local requirements without branching logic inside generated blocks.
What are the common pitfalls and debugging strategies for dynamic blocks?
Dynamic blocks introduce indirection that obscures errors. After years of maintaining infrastructure for eCommerce platforms and legal-tech systems, I’ve catalogued the failures that repeatedly trip up teams. Avoiding these saves days of debugging per quarter.
Pitfall 1: Using list indices as stable identifiers. When for_each iterates over a list, the iterator’s .key is the numeric index. Reordering the list changes every key, causing Terraform to destroy and recreate all generated blocks even though the content is identical. Always convert lists to maps with stable keys before passing to dynamic:
# BAD — reordering causes full replacement
dynamic "tag" {
for_each = var.tags_list # list(object)
content { ... }
}
# GOOD — stable keys survive reordering
locals {
tags_map = { for t in var.tags_list : t.key => t }
}
dynamic "tag" {
for_each = local.tags_map
iterator = t
content {
key = t.key
value = t.value.value
}
} Pitfall 2: Empty collections producing zero blocks silently. If for_each receives an empty list or map, the dynamic block generates nothing. This is correct behavior but surprises engineers who expect at least one default block. Add explicit validation or a fallback:
variable "required_ingress" {
type = list(any)
validation {
condition = length(var.required_ingress) > 0
error_message = "At least one ingress rule is required."
}
}
# Or provide a safe default in locals
locals {
effective_rules = length(var.ingress_rules) > 0 ? var.ingress_rules : [{
from_port = 443, to_port = 443, protocol = "tcp",
cidr_blocks = ["10.0.0.0/8"], description = "Default HTTPS"
}]
} Pitfall 3: Provider schema mismatches. Dynamic blocks must produce exactly the attributes the provider expects. Optional attributes omitted from content are fine, but misspelled or extra attributes fail at plan time. Always run terraform providers schema -json | jq '.provider_schemas["registry.terraform.io/hashicorp/aws"].resource_schemas["aws_security_group"].block.nested_blocks.ingress.block.attributes' to inspect the exact schema before writing dynamic content. Don’t guess from documentation alone — docs lag behind provider releases.
Debugging workflow: When a dynamic block produces unexpected output, temporarily replace it with static blocks generated via terraform console or a output block using jsonencode. Inspect the resolved structure before applying. For complex nested dynamics, add output "debug_dynamic" { value = [for r in var.rules : { name = r.key, ports = r.value.from_port }] } to verify iteration order and values. Remove debug outputs before committing. This technique has saved me countless hours when troubleshooting WAF rules and Kubernetes manifest generators for clients who need rapid iteration cycles.
When should you avoid Terraform dynamic blocks entirely?
Not every repetition warrants a dynamic block. Overuse creates clever-but-unreadable code that burdens future maintainers. Apply these decision criteria rigorously:
- Use dynamic blocks when the nested block count varies based on input variables and the block type is defined by the provider schema (ingress, tag, setting, permission, statement).
- Use static blocks when the structure is fixed regardless of environment. Three hardcoded ingress rules for a standard web tier are clearer than a dynamic block iterating over a constant list.
- Use separate resources with for_each when each generated item should be independently manageable in state. Security group rules as
aws_security_group_ruleresources withfor_eachallow adding/removing individual rules without touching the parent security group. This reduces blast radius in production. - Use modules when the repeated structure represents a cohesive unit of infrastructure. A module encapsulating a complete load balancer listener with certificates and rules is more reusable than a dynamic block buried in a monolithic resource.
Performance matters at scale. Terraform evaluates every for_each expression during planning. Dynamic blocks with large collections (hundreds of rules) slow down plans noticeably. If you’re generating 200+ blocks dynamically, reconsider whether those should be separate resources managed in parallel or split across multiple module instances. I’ve seen plan times drop from 4 minutes to 45 seconds by converting a massive dynamic WAF rule block into individually managed rule resources with for_each.
Readability trumps DRY. If a colleague needs to understand what infrastructure exists by reading the code, five explicit static blocks beat a dynamic block referencing a variable defined three files away. Reserve dynamic blocks for genuine variability — environment-specific tags, tenant-dependent permissions, region-aware network rules. For everything else, explicitness wins. This philosophy aligns with how I approach DevOps automation for Nepal-based businesses where team turnover and knowledge transfer are real concerns.
Terraform Dynamic Blocks in Practice: Shipping Reliable Infrastructure
Mastering Terraform dynamic blocks in practice means knowing when to use them, how to structure them safely, and when to choose alternatives. Start with explicit iterator names, validate inputs at variable boundaries, filter collections before iteration, and never nest without unique scopes. Test with terraform plan and inspect resolved outputs before applying to production. Pair dynamic blocks with strong typing, comprehensive validation, and clear documentation to build modules that teams trust. If your infrastructure code feels fragile or your plans take too long, revisit whether dynamic blocks are the right abstraction. For teams ready to professionalize their IaC workflows, especially those managing multi-region deployments or compliance-sensitive systems, reach out via /contact-me to discuss architecture reviews or hands-on implementation support.

