Dynamic blocks let you generate repeatable nested configurations in Terraform, but they’re not just syntactic sugar for loops; they fundamentally change how Terraform processes your configuration by deferring decisions about block structure until apply time.
Let’s see one in action. Imagine you have a security group rule, and you want to allow traffic from several different CIDR blocks.
variable "allowed_cidrs" {
description = "A list of CIDR blocks allowed for SSH access."
type = list(string)
default = ["192.168.1.0/24", "10.0.0.0/16"]
}
resource "aws_security_group" "example" {
name = "example-sg"
description = "Example security group"
ingress {
description = "Allow SSH from specified CIDRs"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_cidrs # This is the standard way for a single attribute
}
}
This works fine if cidr_blocks is a single attribute that accepts a list. But what if your resource requires multiple cidr_blocks blocks within the ingress block, each with its own list of CIDRs? For example, some older AWS resources or custom providers might have a structure like this:
ingress {
protocol = "tcp"
from_port = 80
to_port = 80
cidr_blocks {
ipv4 = ["192.168.1.0/24"]
}
cidr_blocks {
ipv4 = ["10.0.0.0/16"]
}
}
Here, cidr_blocks isn’t an attribute that takes a list; it’s a nested block that can appear multiple times. If var.allowed_cidrs is ["192.168.1.0/24", "10.0.0.0/16"], you can’t just assign it to cidr_blocks.ipv4 directly. You need a way to dynamically create multiple cidr_blocks blocks, one for each CIDR. This is where dynamic blocks shine.
Here’s how you’d use a dynamic block to achieve that:
variable "allowed_cidrs_complex" {
description = "A list of CIDR blocks for a complex ingress rule."
type = list(object({
name = string
cidrs = list(string)
}))
default = [
{
name = "internal_network"
cidrs = ["192.168.1.0/24"]
},
{
name = "management_network"
cidrs = ["10.0.0.0/16"]
}
]
}
resource "aws_security_group" "example_complex" {
name = "example-complex-sg"
description = "Example security group with dynamic ingress"
ingress {
description = "Allow HTTP from specified CIDRs"
from_port = 80
to_port = 80
protocol = "tcp"
dynamic "cidr_blocks" {
for_each = var.allowed_cidrs_complex
content {
# You can reference attributes of the iterated object
# The 'cidr_blocks' iterator has 'key' and 'value' attributes
# 'value' here is the object { name = string, cidrs = list(string) }
# We're using 'value.cidrs' to populate the ipv4 attribute of the nested block.
# Note: The actual attribute name might vary based on the provider's schema.
# For this example, we assume it's 'ipv4'. If it were 'ipv4_cidr', you'd use that.
ipv4 = cidr_blocks.value.cidrs
}
}
}
}
In this example:
dynamic "cidr_blocks"declares that we’re going to dynamically generate one or morecidr_blocksnested blocks.for_each = var.allowed_cidrs_complexiterates over the list of objects defined invar.allowed_cidrs_complex. For each object in the list, Terraform will create onecidr_blocksblock.content { ... }defines the structure of each generatedcidr_blocksblock. Inside this block, you can usecidr_blocks.keyandcidr_blocks.valueto reference the current item being iterated over.cidr_blocks.valueis the entire object from the list, andcidr_blocks.value.cidrsaccesses the list of CIDRs for that specific object.
The for_each meta-argument is crucial here. It tells Terraform to loop through the provided collection (a list, set, map, or even another dynamic block) and create a separate instance of the dynamic block for each element. The content block then defines what goes inside each of those generated blocks.
The most surprising truth about dynamic blocks is that they don’t just generate configuration that Terraform reads at plan time; the structure of the generated configuration is determined at apply time based on the evaluated for_each expression and the content. This means that if your for_each expression changes between applies (e.g., you add or remove a CIDR from your variable), Terraform will create or destroy the corresponding nested blocks, just as it would with any other resource or block. The dynamic block acts as a template that gets materialized into concrete configuration blocks during the apply phase.
When you run terraform plan with the complex example, Terraform will analyze var.allowed_cidrs_complex. It sees two items in the list, so it knows it needs to generate two cidr_blocks blocks within the ingress block. The plan output will show the aws_security_group resource with two distinct ingress.cidr_blocks blocks, each populated with the correct CIDR list from your variable.
The mental model to build around dynamic blocks is that they are a way to programmatically construct nested resource configuration. Think of them as a templating engine specifically for Terraform’s block structures. You provide a data source (like a list or map) and a template for the nested block, and Terraform materializes that template into concrete blocks based on your data. The for_each is the loop counter, and the content is the body of the loop, defining the actual Terraform code for each iteration. The iterator.value and iterator.key are how you access the current item’s data within the loop.
One thing most people don’t know is how for_each interacts with complex types like maps of objects. If your for_each expression is a map, the dynamic block’s iterator will expose iterator.key as the map key and iterator.value as the corresponding map value (the object in this case). This allows for very flexible configuration generation where not only the contents but also potentially the names or identifiers of nested blocks could be derived from map keys.
The next concept you’ll likely explore is using for_each within dynamic blocks to create deeply nested or highly conditional configurations.