Terraform dynamic blocks let you generate repeated nested blocks inside a resource, data source, provider, or provisioner configuration. They are useful when the block you want to repeat is not a top-level resource but a nested configuration block such as ingress, egress, statement, or another provider-defined structure.
A normal resource block with count or for_each repeats the entire resource. A dynamic block repeats just one nested portion inside the resource.
That is the key idea.
For example, an AWS security group can contain multiple ingress blocks. If the number of ingress rules varies by environment or is driven by input data, hardcoding every ingress block can become repetitive. A dynamic block lets Terraform generate those nested blocks from a collection.
Many provider schemas contain nested blocks rather than flat arguments. Examples include:
ingress and egress rulesIf the number of nested blocks is fixed and small, writing them directly is often best. But when the structure is variable, duplicated, or based on input data, dynamic blocks reduce repetition.
The goal is not to make Terraform feel like a general-purpose programming language. The goal is to keep configuration declarative while still allowing repeated nested structures.
Here is the general form:
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
}
}Break it down:
dynamic "ingress" says Terraform will generate one or more ingress blocksfor_each decides how many blocks to createcontent { ... } defines what each generated block containsingress.value refers to the current itemThis syntax can feel unusual at first because it is not the same each.key/each.value pattern you use on resources. Inside a dynamic block, the iterator defaults to the block label unless you explicitly change it.
Suppose you want to create a security group with several ingress rules based on a variable.
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
]
}
resource "aws_security_group" "web" {
name = "web-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
}
}
}Terraform will generate two ingress blocks, one for port 80 and one for port 443.
This is the most common beginner use case for dynamic blocks.
contentThe current item is usually accessed through <block_label>.value.
In the previous example:
ingress.value.from_portingress.value.to_portingress.value.protocolingress.value.cidr_blocksIf the collection is a map, you can also use the key.
Terraform also supports an explicit iterator argument if you want a different name:
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule
content {
from_port = rule.value.from_port
to_port = rule.value.to_port
}
}This can improve readability, especially when nested dynamic blocks are involved.
Security groups are a great teaching example because AWS security groups often need several nearly identical nested blocks.
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}This is perfectly fine when the rule set is small and fixed.
resource "aws_security_group" "web" {
name = "web-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
}
}
}This is better when:
Dynamic blocks can also help when nested policy fragments vary by input.
For example, imagine a module builds multiple statements from a collection.
variable "policy_statements" {
type = list(object({
sid = string
actions = list(string)
resources = list(string)
}))
}
data "aws_iam_policy_document" "app" {
dynamic "statement" {
for_each = var.policy_statements
content {
sid = statement.value.sid
actions = statement.value.actions
resources = statement.value.resources
}
}
}This pattern is helpful when different callers need different policy statements but the overall document structure remains the same.
Instead of hardcoding five statement blocks and editing them manually for each project, you let input data describe the policy shape.
That makes modules more reusable while still keeping policy logic explicit.
Terraform also supports nested dynamic blocks when the provider schema contains repeated nested blocks inside repeated nested blocks.
Example pattern:
dynamic "statement" {
for_each = var.policy_statements
content {
sid = statement.value.sid
dynamic "condition" {
for_each = statement.value.conditions
content {
test = condition.value.test
variable = condition.value.variable
values = condition.value.values
}
}
}
}This is powerful, but it can become difficult to read quickly.
Use them when:
Avoid them when:
This is one of the most important design decisions.
| Approach | Best when | Tradeoff |
|---|---|---|
| Hardcoded nested blocks | Small, fixed configuration | More repetition |
| Dynamic blocks | Variable or input-driven nested configuration | More abstraction |
If you only have two ingress rules and they will never change, writing them directly is often the best answer. Anyone reading the resource sees exactly what is allowed.
If a module should accept a caller-defined set of rules, a dynamic block usually gives the right balance of flexibility and structure.
Dynamic blocks are useful, but they are easy to overuse.
Do not use them just because you can. They are not automatically better than explicit blocks.
This is too abstract for little gain:
dynamic "ingress" {
for_each = [80, 443]
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}It works, but two explicit ingress blocks may be easier for a beginner to read.
Security policies and firewall rules are high-impact infrastructure. If abstraction makes them harder to audit, it may not be worth it.
Sometimes a large, deeply nested variable structure is harder to maintain than a few repeated blocks. In those cases, simplify the interface instead of adding more dynamic logic.
Here is a realistic security group module pattern.
variable "name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "ingress_rules" {
type = list(object({
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
}
resource "aws_security_group" "this" {
name = var.name
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule
content {
description = rule.value.description
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}This keeps the security group reusable while leaving the default egress rule explicit and easy to audit.
If the entire resource is repeated, count or for_each is usually the right tool. Dynamic blocks solve a different problem.
List or map objects with clear fields make dynamic blocks easier to read and validate.
iteratorIf the default label-based iterator is confusing, rename it to something clearer.
Clarity is often more valuable than removing a few lines.
Anything involving ports, CIDRs, principals, or policy statements should remain easy to inspect.
Dynamic blocks repeat nested blocks, not top-level resources.
If the dynamic version feels more complicated than the hardcoded version, it probably is.
Inside dynamic "ingress", the current item is ingress.value unless you define another iterator name.
Nested dynamic blocks can be valid, but they should serve a clear purpose.
Terraform dynamic blocks are a focused tool for a specific job: generating repeated nested configuration. They are especially useful in reusable modules where nested blocks such as ingress rules or IAM statements vary based on input data.
The most important ideas are:
for_each decides how many nested blocks are generatedcontent defines the body of each generated blockIf explicit blocks are clearer, use explicit blocks. If nested configuration truly needs to be driven by data, dynamic blocks can make Terraform modules much more flexible without losing structure.
<MCQExercise exercise={{ id: 'tf-dynamic-q1', title: 'Question 1: Purpose', description: 'What problem do Terraform dynamic blocks primarily solve?', options: [ { id: 'a', text: 'Creating multiple nested blocks inside a resource from input data' }, { id: 'b', text: 'Replacing terraform init and terraform plan' }, { id: 'c', text: 'Encrypting Terraform state automatically' }, { id: 'd', text: 'Publishing module outputs to the Registry' }, ], correctId: 'a', explanation: 'Dynamic blocks are designed to generate repeated nested blocks such as ingress rules or statement blocks from a collection.', wrongExplanations: { b: 'Dynamic blocks are a configuration feature, not a workflow replacement.', c: 'State encryption is handled by backend and platform choices, not dynamic blocks.', d: 'Registry publishing is unrelated to dynamic blocks.', }, }} />
<MCQExercise exercise={{ id: 'tf-dynamic-q2', title: 'Question 2: Syntax', description: "Inside dynamic "ingress" { ... }, which expression normally accesses the current item's value?", options: [ { id: 'a', text: 'count.index' }, { id: 'b', text: 'ingress.value' }, { id: 'c', text: 'dynamic.current' }, { id: 'd', text: 'var.ingress.current' }, ], correctId: 'b', explanation: 'By default, the iterator name matches the dynamic block label, so the current item is accessed with ingress.value.', wrongExplanations: { a: 'count.index is used with counted resources, not dynamic block iterators.', c: 'dynamic.current is not valid Terraform syntax.', d: 'var.ingress.current is not how dynamic iterators are referenced.', }, }} />
<MCQExercise exercise={{ id: 'tf-dynamic-q3', title: 'Question 3: Overuse', description: 'When should you usually avoid dynamic blocks?', options: [ { id: 'a', text: 'When the nested configuration is small, fixed, and clearer as explicit blocks' }, { id: 'b', text: 'Whenever a resource contains any nested block at all' }, { id: 'c', text: 'Whenever the configuration includes a list variable' }, { id: 'd', text: 'Only when using the AWS provider' }, ], correctId: 'a', explanation: 'If the structure is tiny and unlikely to change, explicit nested blocks are often clearer and easier to audit than a dynamic abstraction.', wrongExplanations: { b: 'Many resources contain nested blocks, and some are excellent candidates for dynamic generation.', c: 'A list variable alone does not mean dynamic blocks are a bad idea.', d: 'The guidance applies across providers, not just AWS.', }, }} />
When the number of nested blocks genuinely varies. Security group rules driven by a variable, tags on a resource type that uses block syntax, environment settings whose count differs by caller. Do not use one for a fixed, small number of blocks: two hand-written ingress blocks are far easier to read than a dynamic block over a two-element list, and readability matters more than avoiding repetition at that scale.
for_each on a resource creates multiple resources; a dynamic block creates multiple nested blocks inside one resource. Five subnets means for_each on aws_subnet. Five ingress rules inside one security group means a dynamic "ingress" block. Choosing the wrong one produces either a single resource that should have been several, or several resources that should have been one.
The collection it iterates over is empty, which produces zero blocks and no error. That is usually correct behaviour, but it surprises people who expected a default. Check the value with terraform console and confirm it is populated. The other frequent cause is referencing the wrong iterator name. Inside dynamic "ingress" the variable is ingress.value unless you override it with iterator.
Yes, and it is occasionally necessary for deeply structured resources. It is also where Terraform configuration becomes genuinely hard to read, so treat a second level of nesting as a signal to reconsider the data structure. Flattening the input into a list of fully-formed objects usually produces a simpler configuration than two nested loops. Run terraform console or a plan and read the rendered output when nesting, because the mistakes are not obvious from the source.
Often, yes. Separate aws_vpc_security_group_ingress_rule resources with for_each give each rule its own state address, so adding or removing one shows as a single change rather than a rewrite of the whole security group, and a description per rule is preserved cleanly. Dynamic blocks inside aws_security_group remain valid, but the standalone rule resources produce clearer plans on anything that changes regularly.
Learn how Terraform workspaces separate state, the workspace new, select and list commands, terraform.workspace in code, and when to avoid them.
Configure the Terraform AWS provider, authenticate without long-lived keys using assume role and OIDC, manage regions and accounts, and tag everything.
Build a custom AWS VPC with Terraform: public and private subnets, an internet gateway, route tables and a NAT gateway, explained step by step.