DevOpsil
Terraform
82%
Needs Review

Terraform Dynamic Blocks For Managing Variable Infrastructure Resources

Riku TanakaRiku Tanaka9 min read

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.

Share:

Was this article helpful?

Riku Tanaka
Riku Tanaka

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

More in Terraform

View all →
TerraformQuick RefBeginnerNeeds Review

Terraform CLI: Cheat Sheet

Terraform CLI cheat sheet with commands organized by workflow — init, plan, apply, destroy, state manipulation, imports, and workspace management.

Zara Blackwood·
3 min read

Discussion