TerraformVII · Resources, Data Sources, and count/for_eachProduction Terraform
Dynamic Blocks for Conditional Configuration
What you'll learn
- Use dynamic blocks to generate nested configuration from a collection
- Choose the right iterator label and avoid shadowing outer variables
- Recognise when a dynamic block adds more confusion than clarity
- Convert a dynamic block back to literal HCL when the structure is fixed
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A security group needs twenty ingress rules — one per port, one per
CIDR. The team could write them as twenty literal blocks. They could
also write a dynamic block that generates them from a list. The
trade-off is readability versus brevity, and the trade-off is
production-relevant. This lesson is the discipline of when to use
dynamic, when to write it out by hand, and how to make the
dynamic version reviewable.
What a dynamic block does
A dynamic block generates nested configuration blocks from a
collection. The block label (e.g., ingress) becomes the type of
block generated; the for_each collection drives the iteration;
the content block is the template for each generated block.
resource "aws_security_group" "web" {
name = "web"
description = "Web tier"
dynamic "ingress" {
for_each = var.web_ingress_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
With var.web_ingress_ports = [443, 80], Terraform generates two
ingress blocks:
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
The generated blocks are the same as if they had been written
literally. terraform plan shows them as if they were written
literally. The provider sees them as literal blocks.
Iterator labels
The default iterator variable is the block label. For an ingress
block, the iterator is ingress. If the block label collides with
an outer variable (for example, the resource block has an ingress
attribute), the iterator must be renamed:
locals {
web_ingress = [
{ port = 443, cidr = "10.0.0.0/16" },
{ port = 80, cidr = "0.0.0.0/0" },
]
}
resource "aws_security_group" "web" {
name = "web"
description = "Web tier"
dynamic "ingress" {
for_each = { for p in local.web_ingress : p.port => p }
iterator = port
content {
from_port = port.value.port
to_port = port.value.port
protocol = "tcp"
cidr_blocks = [port.value.cidr]
}
}
}
Inside content, the iterator variable is port (not ingress).
Use iterator = <name> to choose a name that does not collide.
When dynamic is the right tool
Use dynamic when:
- The nested blocks are driven by a variable that changes per environment (e.g., a list of ports in a Terraform variable).
- The number of blocks is not known at write time (e.g., one rule per route table association).
- The structure wraps another dynamic block that itself varies
(e.g., dynamic
ingressblocks inside dynamicegressblocks).
A canonical use case: per-environment ingress rules:
variable "ingress_rules" {
type = map(object({
port = number
protocol = string
cidr_blocks = list(string)
}))
default = {}
}
resource "aws_security_group" "app" {
name = "app-${var.environment}"
description = "Application security group"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}
Per environment, the rules differ; the configuration does not need to change.
When dynamic is the wrong tool
Do not use dynamic when:
- The blocks are fixed (the team has decided the structure is stable). Write them literally. Reviewers can read them.
- The number of blocks is small (one or two). The abstraction cost exceeds the brevity savings.
- The blocks control security-sensitive configuration and the
generation logic is hard to audit. Generate them, then
inspect the generated output with
terraform show -json. - The collection is derived from another resource that is itself dynamic. Two layers of dynamic blocks obscure the generated structure.
A counter-example. The team writes:
dynamic "ingress" {
for_each = [
{ port = 80, cidr = "10.0.0.0/16" },
{ port = 443, cidr = "10.0.0.0/16" },
]
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = [ingress.value.cidr]
}
}
The collection is hard-coded. The dynamic block adds complexity
without adding value. The literal version is shorter and easier to
read:
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
A simple rule: if the collection driving the dynamic block is a
literal in the same file, write the blocks literally.
Converting dynamic back to literal HCL
When a dynamic block has grown unweildy, convert it back to
literal blocks. The process is mechanical:
- Run
terraform show -jsonon the current plan to see the generated structure. - Replace the
dynamicblock with the literal blocks from the generated output. - Remove the variable or collection driving the
dynamic. - Verify
terraform planis empty.
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | select(.address == "aws_security_group.app") | .change.after.ingress'
The output is the literal JSON representation of the ingress blocks. Use it as the source of truth when converting.
Production failure modes
-
Iterator name collision. The iterator variable (
ingress) collides with an outer variable or attribute. Symptom: the generated block references the wrong value. Recovery: rename the iterator withiterator = port. -
Hard-coded collection. The
for_eachis a literal list in the same file. Thedynamicblock is an abstraction with no callers. Symptom: reviewers cannot tell why the abstraction exists. Recovery: write the blocks literally. -
Missing key in the driving map. A key is referenced in the content block but is missing from the per-iteration object. Symptom:
Error: Unsupported attributeon the iterator. Recovery: add the missing key to the input variable default, or guard withtry(). -
Nested dynamic blocks. A
dynamicblock contains anotherdynamicblock. Symptom: the generated structure is very hard to read; plan output is large. Recovery: flatten the structure; generate the inner blocks from a precomputed map. -
dynamic "egress"opens the wrong CIDR. A bug in the driving map accidentally includes0.0.0.0/0in a production egress rule. Symptom: the security group allows egress to the entire internet. Recovery: add a policy check (terraform validateplus a Sentinel/Conftest policy); audit the generated security group withterraform show -json. -
Dynamic blocks driving IAM policies. A
dynamic "statement"inside an IAM policy generates wildcard actions. Symptom: the policy grants more than intended; the audit trail shows only thedynamicblock, not the generated JSON. Recovery: prefer literal policies for IAM; ifdynamicis necessary, render the policy to JSON and commit it for review.
Security and performance
Security. Dynamic blocks that drive security-sensitive
configuration (security groups, IAM, network ACLs) must be
auditable. Render the generated structure with
terraform show -json and review it as part of code review. For
high-sensitivity configurations, prefer literal blocks.
Performance. A dynamic block is no slower than the equivalent
literal blocks at runtime. The cost is at parse time and review
time. A configuration with thousands of generated blocks can slow
terraform plan; flatten the structure or move to modules.
What to do in production
- Use
dynamicwhen the nested blocks are driven by a variable or by another dynamic structure. - Use literal blocks when the structure is fixed or the count is small.
- Pick clear iterator names. Avoid the default (
ingress,egress) when those names collide with outer variables. - Render the generated structure with
terraform show -jsonand review it as part of code review. - For security-sensitive configuration, prefer literal blocks. Audit the generated output before approving the apply.
- Add a comment explaining the iteration when the
dynamicblock is non-obvious. The next reader will not have your context.
Verification
# 1. List dynamic blocks in the configuration
grep -rE '^\s*dynamic\s+"' .
# 2. Render the generated structure for one resource
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | select(.address == "aws_security_group.web") | .change.after.ingress'
# 3. Confirm the count of generated blocks matches the input
terraform show -json tfplan | jq '.resource_changes[] | select(.address == "aws_security_group.web") | .change.after.ingress | length'
# 4. Verify the security group on the provider
aws ec2 describe-security-groups --group-ids sg-0123456789abcdef0
# 5. Validate the configuration syntactically
terraform validate
A clean verification:
$ terraform show -json tfplan | jq '.resource_changes[] | select(.address == "aws_security_group.web") | .change.after.ingress | length'
2
The number of generated ingress blocks matches the input.
Knowledge check · 7 questions
Q1. What does a `dynamic` block do?
Q2. What is the default name of the iterator variable inside a `dynamic` block?
Q3. Dynamic blocks drive the same API calls as equivalent literal blocks.
Q4. Which of the following are appropriate uses of a `dynamic` block? (Select all that apply.)
Q5. What command renders the generated structure of a resource for audit?
Q6. A team uses a `dynamic "statement"` block inside an `aws_iam_policy_document` to generate IAM statements from a map. The plan is approved, but the resulting IAM policy grants `s3:*` on `*`. The team expected read-only access. What is the most likely cause?
Q7. How do you rename the iterator variable inside a `dynamic` block?
Passing score: 75%. Answers are checked in this browser.