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:
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:
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:
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:
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:
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:
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:
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:
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:
# Wrong
dynamic "optional_setting" {
for_each = var.optional_value != null ? [var.optional_value] : []
content {
value = optional_setting.value
}
}
# Better
optional_setting = var.optional_valueMistake 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.






Comments
Loading comments…