Terraform Dynamic Blocks For Managing Variable Infrastructure Resources
Terraform Dynamic Blocks: Managing Variable Infrastructure Resources Like a Pro
If you've been working with Terraform for a while, you've probably hit that wall where you need to create resources with configurations that vary based on your environment, user requirements, or other dynamic factors. Maybe you need different security group rules for different environments, or varying numbers of subnets across regions. This is where Terraform's dynamic blocks become your best friend.
Dynamic blocks are one of those features that separate the Terraform beginners from the practitioners who actually have to manage real-world infrastructure at scale. They're not just syntactic sugar – they're essential for building maintainable, DRY infrastructure code that adapts to your needs without copy-paste madness.
Understanding Dynamic Blocks
Dynamic blocks allow you to dynamically construct repeatable nested configuration blocks within Terraform resources. Instead of hard-coding multiple similar blocks, you can generate them programmatically based on variables, locals, or data sources.
The basic syntax follows this pattern:
dynamic "block_name" {
for_each = var.items
content {
# Configuration using block_name.value
}
}
This might look simple, but the power lies in how you structure your data and leverage the for_each mechanism.
Real-World Example: Security Groups with Dynamic Rules
Let's start with a practical example that I've used countless times in production environments. Managing security group rules across different environments is a nightmare without dynamic blocks.
variable "security_group_rules" {
description = "Security group rules configuration"
type = map(object({
type = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = {}
}
resource "aws_security_group" "application" {
name_prefix = "app-sg-"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = {
for key, rule in var.security_group_rules : key => rule
if rule.type == "ingress"
}
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
}
}
dynamic "egress" {
for_each = {
for key, rule in var.security_group_rules : key => rule
if rule.type == "egress"
}
content {
from_port = egress.value.from_port
to_port = egress.value.to_port
protocol = egress.value.protocol
cidr_blocks = egress.value.cidr_blocks
description = egress.value.description
}
}
tags = {
Name = "application-security-group"
}
}
Now you can define your rules in a clean, structured way:
security_group_rules = {
http_ingress = {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP access from internet"
}
https_ingress = {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS access from internet"
}
ssh_ingress = {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
description = "SSH access from private networks"
}
all_egress = {
type = "egress"
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "All outbound traffic"
}
}
This approach gives you incredible flexibility. Different environments can have completely different rule sets just by changing the variable values.
Advanced Pattern: Conditional Dynamic Blocks
Here's where things get interesting. You can make dynamic blocks conditional, which is perfect for optional configurations that only apply in certain scenarios.
variable "enable_monitoring" {
description = "Enable CloudWatch monitoring"
type = bool
default = false
}
variable "monitoring_config" {
description = "Monitoring configuration"
type = object({
metrics = list(object({
metric_name = string
namespace = string
dimensions = map(string)
}))
})
default = {
metrics = []
}
}
resource "aws_launch_template" "application" {
name_prefix = "app-template-"
image_id = var.ami_id
instance_type = var.instance_type
# Only create monitoring blocks if monitoring is enabled
dynamic "monitoring" {
for_each = var.enable_monitoring ? [1] : []
content {
enabled = true
}
}
dynamic "tag_specifications" {
for_each = var.enable_monitoring ? ["instance", "volume"] : []
content {
resource_type = tag_specifications.value
tags = merge(var.tags, {
MonitoringEnabled = "true"
})
}
}
}
# Conditional CloudWatch alarms using dynamic blocks
resource "aws_cloudwatch_metric_alarm" "application_alarms" {
for_each = var.enable_monitoring ? {
for metric in var.monitoring_config.metrics : metric.metric_name => metric
} : {}
alarm_name = "app-${each.value.metric_name}-alarm"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = "2"
metric_name = each.value.metric_name
namespace = each.value.namespace
period = "120"
statistic = "Average"
threshold = "80"
dynamic "dimensions" {
for_each = each.value.dimensions
content {
name = dimensions.key
value = dimensions.value
}
}
alarm_actions = [aws_sns_topic.alerts.arn]
}
Complex Example: Multi-Environment Subnet Configuration
This is where dynamic blocks really shine – handling complex, nested configurations that vary significantly between environments.
variable "subnet_config" {
description = "Subnet configuration per environment"
type = map(object({
availability_zones = list(string)
public_subnets = list(object({
cidr_block = string
tags = map(string)
}))
private_subnets = list(object({
cidr_block = string
tags = map(string)
}))
database_subnets = list(object({
cidr_block = string
tags = map(string)
}))
}))
}
locals {
# Flatten the subnet configurations for easier processing
all_subnets = flatten([
for env, config in var.subnet_config : [
for idx, subnet in config.public_subnets : {
key = "public-${env}-${idx}"
type = "public"
environment = env
az_index = idx
cidr_block = subnet.cidr_block
tags = merge(subnet.tags, { Type = "public", Environment = env })
}
]
])
private_subnets = flatten([
for env, config in var.subnet_config : [
for idx, subnet in config.private_subnets : {
key = "private-${env}-${idx}"
type = "private"
environment = env
az_index = idx
cidr_block = subnet.cidr_block
tags = merge(subnet.tags, { Type = "private", Environment = env })
}
]
])
database_subnets = flatten([
for env, config in var.subnet_config : [
for idx, subnet in config.database_subnets : {
key = "database-${env}-${idx}"
type = "database"
environment = env
az_index = idx
cidr_block = subnet.cidr_block
tags = merge(subnet.tags, { Type = "database", Environment = env })
}
]
])
}
# Create subnets dynamically
resource "aws_subnet" "main" {
for_each = {
for subnet in concat(local.all_subnets, local.private_subnets, local.database_subnets) :
subnet.key => subnet
}
vpc_id = var.vpc_id
cidr_block = each.value.cidr_block
availability_zone = data.aws_availability_zones.available.names[each.value.az_index]
map_public_ip_on_launch = each.value.type == "public"
tags = each.value.tags
}
# Route table associations using dynamic blocks
resource "aws_route_table" "main" {
for_each = toset(["public", "private", "database"])
vpc_id = var.vpc_id
dynamic "route" {
for_each = each.key == "public" ? [1] : []
content {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
dynamic "route" {
for_each = each.key == "private" ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
}
tags = {
Name = "${each.key}-rt"
Type = each.key
}
}
Best Practices and Gotchas
After years of using dynamic blocks in production environments, here are the key lessons I've learned:
1. Keep Your Data Structure Clean
The quality of your dynamic blocks depends heavily on how you structure your input data. Invest time in designing clean, consistent variable structures:
# Good: Consistent, predictable structure
variable "load_balancer_listeners" {
type = map(object({
port = number
protocol = string
ssl_policy = optional(string)
certificate_arn = optional(string)
default_action = object({
type = string
target_group_arn = string
})
}))
}
# Avoid: Inconsistent, hard-to-validate structure
variable "listeners" {
type = any # This is a code smell
}
2. Use Meaningful Iterator Names
Don't stick with the default iterator name. Use names that make your code self-documenting:
# Good
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule
content {
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
}
# Less clear
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port # Less obvious what 'ingress' refers to
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
3. Handle Edge Cases
Always consider what happens when your collections are empty or contain unexpected values:
dynamic "ingress" {
for_each = var.security_group_rules != null ? var.security_group_rules : {}
content {
from_port = lookup(ingress.value, "from_port", 0)
to_port = lookup(ingress.value, "to_port", 0)
protocol = lookup(ingress.value, "protocol", "tcp")
cidr_blocks = lookup(ingress.value, "cidr_blocks", ["0.0.0.0/0"])
}
}
4. Don't Overuse Dynamic Blocks
Just because you can make something dynamic doesn't mean you should. If you have a configuration that rarely changes, hard-coding it might be more readable:
# Sometimes this is better than a dynamic block:
resource "aws_security_group" "web" {
name_prefix = "web-sg-"
vpc_id = var.vpc_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"]
}
# Dynamic block for variable rules
dynamic "ingress" {
for_each = var.additional_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
}
}
}
Observability Considerations
As someone who lives and breathes observability, I always think about how infrastructure changes affect monitoring and alerting. Dynamic blocks can make it challenging to track resource changes, so consider adding explicit tagging and naming conventions:
dynamic "ingress" {
for_each = var.security_group_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 = "Rule: ${ingress.key} | Port: ${ingress.value.from_port}-${ingress.value.to_port} | Created: ${timestamp()}"
}
}
This makes it much easier to trace changes in your infrastructure and correlate them with monitoring data.
Wrapping Up
Dynamic blocks are a powerful feature that can dramatically reduce code duplication and increase flexibility in your Terraform configurations. They're especially valuable when you're managing infrastructure across multiple environments or need to handle variable requirements.
The key is to use them judiciously – they add complexity, so make sure the benefits outweigh the costs. Start with simple use cases like the security group rules example, then gradually work your way up to more complex scenarios as you get comfortable with the patterns.
Remember, the goal isn't to make everything dynamic – it's to make your infrastructure code maintainable, predictable, and suited to your actual operational needs. Dynamic blocks are just one tool in your toolkit, but when used correctly, they're incredibly powerful.
Was this article helpful?
SRE & Observability Engineer
If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.
Related Articles
Fix Terraform State Lock Stuck After Failed Apply
Safely unlock Terraform state when a lock is stuck after a crashed apply, CI timeout, or interrupted operation.
Terraform "Error: No Valid Credential Sources Found" On AWS — Fixing Authentication And Provider Configuration Issues
If you've spent any time with Terraform and AWS, you've almost certainly run into this gem at some point: It's one of those errors that looks simple but ca...
Terraform "Error: Cycle" In Resource Dependencies — How To Detect And Break Circular References
If you've spent any meaningful time with Terraform, you've probably encountered this at the worst possible moment: Everything was working fine, you added o...
Terraform Error: Invalid For_each Argument Must Be A Map Or Set Of Strings - Complete Fix Guide
If you've been working with Terraform's `for_each` argument, you've probably hit this error at least once. Don't worry – it's one of those frustrating but...
Fix Terraform 'Error Acquiring the State Lock' with DynamoDB
Resolve Terraform DynamoDB state lock errors caused by permission issues, missing tables, or misconfigured backends.
Writing Reusable Terraform Modules: A Practical Guide
Learn how to write Terraform modules that are actually reusable — with input validation, sensible defaults, and clean interfaces across environments.
More in Terraform
View all →Terraform CLI: Cheat Sheet
Terraform CLI cheat sheet with commands organized by workflow — init, plan, apply, destroy, state manipulation, imports, and workspace management.
Terraform from Zero to Production: Project Structure, Modules, State, and CI/CD
Build production-grade Terraform infrastructure — project structure, module design, state management, testing, and CI/CD pipeline integration.
Testing Terraform with Terratest: A Practical Guide
How to write unit and integration tests for Terraform modules using Terratest — because untested infrastructure is a liability.
Terraform Module Design Patterns for Large Teams
Battle-tested Terraform module patterns for teams — from file structure to versioning to composition. If it's not in code, it doesn't exist.