Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Terraform Dynamic Blocks in Practice

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.

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.

for_each (Resource Level)Resource Ainstance-1Resource Binstance-2Resource Cinstance-3Creates N separateresource instancesState: 3 objectsdynamic (Block Level)Single ResourceNested Block 1Nested Block 2Nested Block 3Generates N blocksState: 1 object
for_each multiplies resources while dynamic blocks multiply nested configurations inside one resource — a critical distinction for Terraform dynamic blocks in practice

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".

Criteriafor_eachdynamic block
ScopeResource-level multiplicationBlock-level generation within one resource
State impactCreates/removes entire resource instancesModifies configuration of existing resource
Syntax locationTop-level meta-argument on resource/data/moduleNested inside resource body replacing static block
Iterator accesseach.key and each.valueCustom label (default: block name) with .key/.value
Best forMultiple similar resources (instances, buckets, users)Repeated nested configs (rules, tags, settings, permissions)
Drift riskModerate — key changes cause destroy/createLower — 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:

  1. The label ("ingress") must exactly match the nested block name defined in the provider schema. Misspelling it causes an "Unsupported block type" error.
  2. The for_each argument accepts a list, map, or set. For lists, the iterator’s .key is the numeric index; for maps, it is the map key. Always prefer maps with meaningful keys when order independence matters.
  3. The content block contains the actual nested block body. References use iterator_name.value and iterator_name.key. If you omit the iterator argument, 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.

Dynamic Block Anatomydynamic "ingress"① Label = nested block nameMust match provider schema exactlyfor_each = var.rules② Collection to iteratelist, map, or set of objectsiterator = rule③ Custom iterator nameAvoids shadowing in nested dynamicscontent { ... }rule.value.from_port④ Generated block bodyReferences use iterator.value/.key
Four mandatory components of a valid dynamic block — getting any wrong causes plan failure or silent misconfiguration in Terraform dynamic blocks in practice

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.

Nested Dynamic Scope & FilteringOuter: dynamic "rule"iterator = rfor_each = var.waf_rules✓ r.key, r.value accessible✓ Filtered BEFORE iterationInner: dynamic "statement"iterator = stmt (UNIQUE!)for_each = r.value.match_statements✓ stmt.value → current item✓ r.value STILL accessible✗ NEVER reuse 'r' as inner iteratorFiltering Best Practicefor_each = {for k,v in var.rules : k=>vif v.enabled && length(v.cidrs)>0}Clean content blockNo ternaries, no can() checksPlan output clarityDisabled items absent from diff
Nested dynamic blocks require unique iterator names and benefit from pre-filtering to keep content blocks clean in Terraform dynamic blocks in practice

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_rule resources with for_each allow 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.

Frequently Asked Questions

Dynamic blocks generate repeated nested configuration blocks programmatically within a resource, avoiding repetitive static code when defining multiple similar settings like ingress rules or tags.

Avoid them for simple, static configurations where explicit blocks improve readability, or when the nested structure varies significantly between iterations making conditional logic overly complex and hard to debug.

Negligible impact; overhead is milliseconds during evaluation unless iterating thousands of items or combining with expensive data source lookups inside the iterator content block.

Use the dynamic keyword followed by the target block name, then define a for_each argument with your collection and a content block referencing iterator.key and iterator.value to map attributes. In my infrastructure work managing security groups across multiple Nepali client environments, this pattern replaces dozens of duplicate ingress stanzas with a single variable-driven loop that stays readable even as rules scale beyond twenty entries per resource.

Yes, Terraform supports nested dynamic blocks, but limit nesting to two levels maximum to preserve maintainability. On production deployments I have managed, deeper nesting creates debugging nightmares during plan reviews because error messages reference generated indices rather than meaningful names. Prefer flattening complex structures into intermediate locals or separate resources when three or more repetition dimensions exist, keeping each dynamic block focused on one axis of variation.

Use ternary operators or can functions within attribute assignments, or filter the for_each collection before iteration using conditional expressions in locals. I have found that pre-filtering collections outside the dynamic block produces clearer plans and avoids silent failures where invalid combinations generate empty blocks. Never rely solely on null suppression inside content; validate inputs early so terraform validate catches structural problems before apply attempts API calls that fail mid-provisioning.

Resource-level for_each creates entirely separate resource instances keyed by map keys, while dynamic blocks repeat nested configuration sections within a single resource instance. Choose resource for_each when each item needs independent lifecycle management and state tracking. Use dynamic blocks when the parent resource must remain singular but contains variable-length child configurations. Confusing these causes state drift and unnecessary destroy-recreate cycles during refactors I have untangled on legacy infrastructure projects.

Always declare iterator labels explicitly when nesting or when the block name conflicts with reserved words, and use try or lookup functions for optional map keys. Type mismatches between list and map iterators cause cryptic failures during refresh. In practice, I standardize on maps with string keys for all dynamic block collections because they produce stable ordering and meaningful plan diffs compared to lists where index shifts trigger false-positive changes across unrelated attributes.

Yes, but mark the underlying variable as sensitive and avoid interpolating secrets directly into block labels or for_each keys since those appear in plain text within state files and plan output. Pass sensitive values only inside content attribute assignments where Terraform redacts them properly. On legal-tech portals handling document storage credentials, I wrap secret-bearing dynamic configurations in dedicated modules with strict variable validation to prevent accidental exposure during collaborative plan reviews or CI log captures.

The for_each expression likely evaluates to an empty collection due to incorrect filtering, type coercion failure, or undefined variable defaults. Add precondition blocks or validation rules on input variables to surface empty-collection scenarios as explicit errors rather than silent omissions. During troubleshooting on shared EC2 infrastructure, I add temporary output blocks echoing the filtered collection to verify transformation logic before removing them, since terraform console cannot evaluate dynamic block contexts directly.

Use terraform plan with targeted resources and inspect the generated configuration in JSON output via terraform show -json to verify block expansion matches expectations. Write unit tests with terratest or assert frameworks validating module outputs against fixture data. On client projects, I maintain example configurations in examples directories that exercise edge cases like empty lists and maximum cardinality, running them through CI pipelines to catch regressions before merging changes that could silently drop critical firewall rules or IAM policy statements.

State stores expanded block contents identically to static blocks, so size depends on final rendered count not source abstraction. However, large for_each collections slow plan evaluation proportionally during graph construction. For resources exceeding fifty dynamic iterations, consider splitting into multiple resources with resource-level for_each or batching via provider-specific bulk APIs. I have seen security group modules with hundreds of inline rules cause minute-long plan phases that resolved after extracting rules into separate aws_security_group_rule resources with individual lifecycle management.

First refactor static blocks into a local variable matching your intended for_each structure, run terraform plan to confirm zero changes, then replace static blocks with dynamic references incrementally. Never convert both structure and values simultaneously. On infrastructure migrations I supervise, we require plan equivalence verification at each step and retain git history showing before-after state parity. Skipping intermediate validation risks accidental resource replacement when attribute ordering shifts or key naming conventions change during abstraction.

Providers may treat block order as significant even when semantically irrelevant, causing perpetual diffs when for_each iterates over unordered sets. Always sort collections deterministically using sort or keys functions before iteration. Some providers normalize returned block order differently than HCL declaration order, triggering false drift detection. In production systems I maintain, pinning iteration order via sorted map keys eliminated spurious plan noise that previously confused operators into believing unauthorized changes occurred during routine maintenance windows.

Yes, consider provider-specific bulk attributes accepting lists or maps natively, custom provider resources designed for batch operations, or external preprocessing with templating tools for extreme complexity. Modules wrapping repetitive patterns behind simplified interfaces often outperform raw dynamic blocks for team consumption. On projects where junior engineers maintain infrastructure, I prefer well-documented module abstractions over exposing dynamic block mechanics directly, reducing cognitive load while preserving flexibility through curated variable surfaces validated at module boundaries rather than scattered throughout calling configurations.

Share this article

Quick Contact Options
Choose how you want to connect me: