Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 019 / 100

Dynamic blocks in Terraform: what developers should know

Generate nested blocks from data.

Expona Admin6 min read

When you're managing infrastructure as code at scale, hardcoding every security group rule, route, or policy statement becomes unmaintainable. Dynamic blocks in Terraform let you generate nested blocks from data structures, turning repetitive configurations into clean, parameterized code. We'll walk you through the mechanics, show you where they shine, and highlight the pitfalls we've encountered in production.

What dynamic blocks actually do

A dynamic block is a meta-argument that instructs Terraform to construct nested blocks by iterating over a data structure. Instead of manually writing five separate ingress rules, you define the pattern once and let Terraform repeat it for each item in your list or map.

The syntax is straightforward:

hcl
dynamic "block_type" {
  for_each = var.items
  content {
    # References to block_type.key are available here
  }
}

The key insight is that dynamic blocks work with any nested block—not just in security groups. We've used them for route tables, IAM policies, tags, event rules, and S3 lifecycle policies. The iterator variable (defaulting to the block type name) gives you access to both the key and value of each item in the loop.

Dynamic "ingress" for security group rules

Security groups are the canonical use case. Rather than writing a separate ingress block for HTTP, HTTPS, SSH, and custom protocols, define your rules as data and iterate:

hcl
variable "ingress_rules" {
  type = list(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
    description = string
  }))
  default = []
}

resource "aws_security_group" "api_server" {
  name   = "api-server-sg"
  vpc_id = aws_vpc.main.id

  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
      description = ingress.value.description
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Then in your variables or tfvars file:

hcl
ingress_rules = [
  {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow HTTP"
  },
  {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow HTTPS"
  },
  {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]
    description = "Allow SSH from internal"
  }
]

This pattern scales effortlessly. Adding a new rule is just another object in the list. Removing one is a single line deletion. No copy-paste errors, no accidental duplication of resource blocks.

Keep data structures simple

The biggest mistake we've made—and seen repeated in many codebases—is over-engineering the data structure feeding a dynamic block. Resist the urge to nest everything. Flat, predictable structures are easier to read, maintain, and debug.

Consider this anti-pattern:

hcl
variable "complex_config" {
  type = map(object({
    rules = list(object({
      access = object({
        level = string
        scope = object({
          type = string
          ids  = list(string)
        })
      })
    }))
  }))
}

When you hit a runtime error, tracing through three levels of nesting to find the problem is painful. Keep it simple:

hcl
variable "firewall_rules" {
  type = list(object({
    name       = string
    direction  = string
    protocol   = string
    port_start = number
    port_end   = number
    source_ips = list(string)
  }))
}

Each attribute should map directly to a resource argument. Your future self (and your teammates) will thank you when troubleshooting is straightforward.

Beyond ingress: using dynamic blocks elsewhere

Dynamic blocks are useful everywhere there's repetition. We regularly use them for:

  • S3 lifecycle policies: Multiple expiration, transition, or noncurrent version rules.
  • Route table routes: Several destination CIDR blocks pointing to the same target.
  • EventBridge rules: Multiple targets with different input transformations.
  • Lambda environment variables: When the set of variables is computed or environment-dependent.
  • RDS parameter groups: Database parameters that vary across environments.

Example with S3 lifecycle:

hcl
variable "lifecycle_rules" {
  type = list(object({
    id              = string
    status          = string
    expiration_days = number
  }))
}

resource "aws_s3_bucket_lifecycle_configuration" "archive" {
  bucket = aws_s3_bucket.archive.id

  dynamic "rule" {
    for_each = var.lifecycle_rules
    content {
      id     = rule.value.id
      status = rule.value.status

      expiration {
        days = rule.value.expiration_days
      }
    }
  }
}

Avoid deep nesting

Terraform supports nested dynamic blocks—you can have a dynamic block inside a dynamic block. In our experience, this almost always signals a design problem. Nesting dynamic blocks quickly becomes unreadable and difficult to debug.

If you find yourself writing:

hcl
dynamic "rule" {
  for_each = var.rules
  content {
    dynamic "action" {
      for_each = rule.value.actions
      content {
        # ...
      }
    }
  }
}

Stop. Flatten your data structure instead. Terraform's flatten, merge, and concat functions are your friends. Compute a single flat list of items before the loop, and use a single dynamic block:

hcl
locals {
  flattened_actions = flatten([
    for rule in var.rules : [
      for action in rule.actions : {
        rule_id = rule.id
        action  = action
      }
    ]
  ])
}

resource "some_resource" "example" {
  dynamic "action" {
    for_each = local.flattened_actions
    content {
      rule_id = action.value.rule_id
      name    = action.value.action.name
    }
  }
}

This approach is more verbose upfront but dramatically easier to understand and troubleshoot.

Common mistakes

Mistake 1: Forgetting the iterator reference. Inside the content block, use block_type.value and block_type.key, not the original variable. For security group rules, it's ingress.value.from_port, not var.ingress_rules[...].from_port.

Mistake 2: Using dynamic blocks for optional arguments. If an argument might be absent, don't force it into a dynamic block. Instead, use the count or try function:

hcl
# Wrong
dynamic "optional_setting" {
  for_each = var.optional_value != null ? [var.optional_value] : []
  content {
    value = optional_setting.value
  }
}

# Better
optional_setting = var.optional_value

Mistake 3: Complex computed values inside dynamic blocks. Move computation to locals before the loop. This keeps the content block clean and makes debugging easier.

Mistake 4: Not testing with empty lists. Always test your dynamic block with zero items. An empty for_each should produce no nested blocks—it usually does, but edge cases exist.

Key takeaways

  • Dynamic blocks generate nested blocks from data structures, eliminating repetitive resource block declarations.
  • Security groups are the most common use case, but dynamic blocks work for any nested block across AWS, GCP, and Azure resources.
  • Keep your input data flat and simple—each attribute should map directly to a resource argument.
  • Avoid nesting dynamic blocks within dynamic blocks; instead, flatten your data with locals and use a single loop.
  • Test your dynamic blocks with empty inputs to ensure they degrade gracefully.
  • Use the iterator variable (ingress.value, rule.value) correctly; it's the handle to each item in your loop.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles