Skip to content
Infrastructure as Code iac hcl 5 min read

Dynamic Blocks

Many Terraform resources accept nested blocks — repeated configuration blocks like ingress rules on a security group or setting blocks on an Elastic Beanstalk environment. When the number of these blocks depends on a variable or a computed collection, you can’t hardcode them. The dynamic block solves this: it expands a single template into zero or more nested blocks, driven by any iterable value. Think of it as a for expression that produces blocks instead of values.

The problem dynamic blocks solve

Without dynamic, you must write each nested block by hand. That’s fine for one or two static rules, but it breaks down when the rules come from a variable list. Consider a security group whose allowed ports are configurable per environment — you cannot know at authoring time how many ingress blocks you need.

A dynamic block lets you declare the shape of one nested block once, then tell Terraform which collection to iterate over. Terraform generates one nested block per element.

Anatomy of a dynamic block

A dynamic block has three parts:

PartPurpose
Block labelThe name of the nested block being generated (e.g. dynamic "ingress").
for_eachThe collection to iterate. Can be a list, set, or map.
iteratorOptional name for the per-element temporary variable (defaults to the block label).
contentThe body emitted for each element, referencing the iterator’s .key / .value.

The content block is the template. Inside it, you reference the current element through the iterator object, which exposes .key (index for lists/sets, key for maps) and .value (the element itself).

Worked example: configurable security group ingress

Suppose you want a reusable security group module whose ingress rules are passed in as a variable.

variable "ingress_rules" {
  description = "Inbound rules to apply to the security group."
  type = list(object({
    description = string
    port        = number
    cidr_blocks = list(string)
  }))
  default = [
    {
      description = "HTTPS"
      port        = 443
      cidr_blocks = ["0.0.0.0/0"]
    },
    {
      description = "SSH from office"
      port        = 22
      cidr_blocks = ["203.0.113.0/24"]
    },
  ]
}

resource "aws_security_group" "web" {
  name        = "web-sg"
  description = "Web tier security group"
  vpc_id      = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      description = ingress.value.description
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }

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

Here the iterator defaults to ingress (the block label), so ingress.value is each object from the list. Running a plan shows the generated blocks expanded inline:

Output:

  # aws_security_group.web will be created
  + resource "aws_security_group" "web" {
      + name = "web-sg"

      + ingress {
          + description = "HTTPS"
          + from_port   = 443
          + to_port     = 443
          + protocol    = "tcp"
          + cidr_blocks = ["0.0.0.0/0"]
        }
      + ingress {
          + description = "SSH from office"
          + from_port   = 22
          + to_port     = 22
          + protocol    = "tcp"
          + cidr_blocks = ["203.0.113.0/24"]
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Because for_each accepts an empty collection, passing ingress_rules = [] simply produces a security group with no ingress blocks — no special casing required.

Using a named iterator

When you nest dynamic blocks, the default iterator names collide. Use the iterator argument to rename the temporary variable so inner and outer loops stay distinct.

resource "aws_security_group" "tiered" {
  name   = "tiered-sg"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.ingress_rules
    iterator = rule
    content {
      description = rule.value.description
      from_port   = rule.value.port
      to_port     = rule.value.port
      protocol    = "tcp"
      cidr_blocks = rule.value.cidr_blocks
    }
  }
}

Filtering and transforming the source collection

You rarely iterate a raw variable as-is. Combine dynamic with a for expression to filter or reshape the input. For example, only emit rules that are enabled:

dynamic "ingress" {
  for_each = { for r in var.ingress_rules : r.description => r if r.enabled }
  content {
    description = ingress.value.description
    from_port   = ingress.value.port
    to_port     = ingress.value.port
    protocol    = "tcp"
    cidr_blocks = ingress.value.cidr_blocks
  }
}

Iterating a map (rather than a list) gives ingress.key a stable, meaningful identifier instead of a positional index, which keeps plans cleaner when elements are added or removed.

Tip: When for_each is driven by a set or map, the .key is stable across runs. With a list, the key is the positional index, so inserting an element in the middle shifts every following block. Prefer maps or sets for collections that change over time.

When to use — and when to avoid

Reach for dynamic only when the count of nested blocks is genuinely data-driven. Overusing it makes configuration hard to read.

Warning: Avoid dynamic for blocks that are always present and static. Hardcoded nested blocks are clearer, diff better, and are easier to review. The HashiCorp style guide explicitly recommends using dynamic sparingly.

This syntax is identical in OpenTofu — dynamic, for_each, iterator, and content behave the same way, so modules using dynamic blocks port between the two without changes.

Best practices

  • Use dynamic only when the number of nested blocks is variable or computed; write static blocks by hand.
  • Prefer maps or sets over lists for for_each so block keys stay stable across edits and produce minimal diffs.
  • Combine dynamic with for expressions to filter (if) and reshape inputs rather than pre-processing in separate locals where it hurts readability.
  • Name the iterator with iterator whenever you nest dynamic blocks to avoid shadowed variable names.
  • Keep content bodies flat and readable; if logic grows, move the data shaping into a locals block.
  • Validate that an empty for_each collection produces the intended result — usually zero blocks, which is a feature, not an error.
Last updated June 14, 2026
Was this helpful?