Skip to main content
Terraform Dynamic Blocks and For Expressions with Examples

Terraform Dynamic Blocks and For Expressions with Examples

Key Takeaway

Learn Terraform dynamic blocks and for expressions. Generate repeated blocks from variables, filter lists, transform maps, and build DRY security groups, IAM policies, and tags.

Table of Contents

Dynamic Blocks

Dynamic blocks generate repeated nested blocks from a variable. Instead of writing the same block 10 times, you write it once with dynamic.

The Problem: Repetitive Blocks

# Without dynamic — 5 ingress blocks, lots of repetition
resource "aws_security_group" "web" {
  name = "web-sg"

  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"]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]
  }
  # ... more rules
}

The Solution: Dynamic Block

variable "ingress_rules" {
  default = [
    { port = 80,  cidr = "0.0.0.0/0",   description = "HTTP" },
    { port = 443, cidr = "0.0.0.0/0",   description = "HTTPS" },
    { port = 22,  cidr = "10.0.0.0/8",  description = "SSH internal" },
    { port = 8080, cidr = "10.0.0.0/8", description = "App internal" },
    { port = 3306, cidr = "10.0.1.0/24", description = "MySQL" },
  ]
}

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.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [ingress.value.cidr]
      description = ingress.value.description
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Dynamic Block Syntax

dynamic "BLOCK_NAME" {
  for_each = COLLECTION
  iterator = OPTIONAL_NAME   # defaults to BLOCK_NAME
  content {
    # Use BLOCK_NAME.value or ITERATOR.value
    attribute = BLOCK_NAME.value.something
  }
}
  • BLOCK_NAME — the nested block to generate (ingress, setting, tag, etc.)
  • for_each — list or map to iterate over
  • iterator — optional custom name (default: block name)
  • content — the block body, with .value for current item and .key for index/key

More Examples

AWS IAM Policy Statements:

variable "policy_statements" {
  default = [
    {
      effect    = "Allow"
      actions   = ["s3:GetObject", "s3:ListBucket"]
      resources = ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"]
    },
    {
      effect    = "Allow"
      actions   = ["dynamodb:GetItem", "dynamodb:PutItem"]
      resources = ["arn:aws:dynamodb:*:*:table/my-table"]
    }
  ]
}

data "aws_iam_policy_document" "app" {
  dynamic "statement" {
    for_each = var.policy_statements
    content {
      effect    = statement.value.effect
      actions   = statement.value.actions
      resources = statement.value.resources
    }
  }
}

Azure Network Security Group Rules:

variable "nsg_rules" {
  default = {
    ssh  = { priority = 100, port = 22,  access = "Allow" }
    http = { priority = 200, port = 80,  access = "Allow" }
    rdp  = { priority = 300, port = 3389, access = "Deny" }
  }
}

resource "azurerm_network_security_group" "web" {
  name                = "web-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  dynamic "security_rule" {
    for_each = var.nsg_rules
    content {
      name                       = security_rule.key
      priority                   = security_rule.value.priority
      direction                  = "Inbound"
      access                     = security_rule.value.access
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = security_rule.value.port
      source_address_prefix      = "*"
      destination_address_prefix = "*"
    }
  }
}

Optional Blocks with Conditional:

variable "enable_encryption" {
  type    = bool
  default = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "example" {
  bucket = aws_s3_bucket.example.id

  dynamic "rule" {
    for_each = var.enable_encryption ? [1] : []
    content {
      apply_server_side_encryption_by_default {
        sse_algorithm = "aws:kms"
      }
    }
  }
}

The for_each = var.enable_encryption ? [1] : [] pattern creates 0 or 1 blocks based on a boolean.

For Expressions

For expressions transform and filter lists and maps inline.

Basic Syntax

# List → List
[for item in list : transform(item)]

# List → Map
{for item in list : key => value}

# With filter
[for item in list : transform(item) if condition]

Transform a List

variable "names" {
  default = ["alice", "bob", "charlie"]
}

locals {
  # Uppercase
  upper_names = [for name in var.names : upper(name)]
  # Result: ["ALICE", "BOB", "CHARLIE"]

  # Add prefix
  prefixed = [for name in var.names : "user-${name}"]
  # Result: ["user-alice", "user-bob", "user-charlie"]
}

Filter a List

variable "instances" {
  default = [
    { name = "web-1",  type = "t3.micro",  env = "prod" },
    { name = "web-2",  type = "t3.small",  env = "prod" },
    { name = "dev-1",  type = "t3.micro",  env = "dev" },
  ]
}

locals {
  # Only prod instances
  prod_instances = [for i in var.instances : i if i.env == "prod"]
  # Result: [{name="web-1",...}, {name="web-2",...}]

  # Just the names
  prod_names = [for i in var.instances : i.name if i.env == "prod"]
  # Result: ["web-1", "web-2"]
}

List to Map

variable "users" {
  default = [
    { name = "alice", role = "admin" },
    { name = "bob",   role = "developer" },
    { name = "carol", role = "admin" },
  ]
}

locals {
  # Create a map keyed by name
  user_map = {for user in var.users : user.name => user.role}
  # Result: {alice = "admin", bob = "developer", carol = "admin"}
}

Map Transformations

variable "tags" {
  default = {
    Environment = "prod"
    Team        = "platform"
    Project     = "api"
  }
}

locals {
  # Prefix all tag keys
  prefixed_tags = {for k, v in var.tags : "app:${k}" => v}
  # Result: {"app:Environment" = "prod", "app:Team" = "platform", ...}

  # Filter tags
  non_env_tags = {for k, v in var.tags : k => v if k != "Environment"}
}

Flatten Nested Lists

variable "vpc_subnets" {
  default = {
    "us-east-1a" = ["10.0.1.0/24", "10.0.2.0/24"]
    "us-east-1b" = ["10.0.3.0/24", "10.0.4.0/24"]
  }
}

locals {
  all_subnets = flatten([
    for az, cidrs in var.vpc_subnets : [
      for cidr in cidrs : {
        az   = az
        cidr = cidr
      }
    ]
  ])
  # Result: [{az="us-east-1a", cidr="10.0.1.0/24"}, ...]
}

resource "aws_subnet" "all" {
  for_each          = {for s in local.all_subnets : "${s.az}-${s.cidr}" => s}
  vpc_id            = aws_vpc.main.id
  availability_zone = each.value.az
  cidr_block        = each.value.cidr
}

Combining Dynamic Blocks with For Expressions

variable "services" {
  default = {
    web = { port = 80,  public = true }
    api = { port = 8080, public = false }
    db  = { port = 5432, public = false }
  }
}

resource "aws_security_group" "app" {
  name   = "app-sg"
  vpc_id = aws_vpc.main.id

  # Only create ingress rules for public services
  dynamic "ingress" {
    for_each = {for k, v in var.services : k => v if v.public}
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = ingress.key
    }
  }

  # Internal services accessible from VPC only
  dynamic "ingress" {
    for_each = {for k, v in var.services : k => v if !v.public}
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [aws_vpc.main.cidr_block]
      description = "${ingress.key} (internal)"
    }
  }
}

When NOT to Use Dynamic Blocks

Dynamic blocks add complexity. Use them when you have 3+ repeated blocks driven by a variable. For 1-2 blocks, write them explicitly — it’s more readable.

# Don't do this for 2 rules — just write them out
dynamic "ingress" {
  for_each = [80, 443]
  content {
    from_port   = ingress.value
    to_port     = ingress.value
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# Better: explicit blocks for 2 rules
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"]
}

Hands-On Courses

Learn by doing with interactive courses on CopyPasteLearn:

Conclusion

Dynamic blocks replace repeated nested blocks with a single dynamic block that iterates over a variable. For expressions transform and filter lists and maps inline. Use them together to build flexible security groups, IAM policies, and multi-AZ subnets from variables. Keep it simple — if you only have 1-2 blocks, write them explicitly.

🚀

Level Up Your Terraform Skills

Hands-on courses, books, and resources from Luca Berton

Luca Berton
Written by

Luca Berton

DevOps Engineer, AWS Partner, Terraform expert, and author. Creator of Ansible Pilot, Terraform Pilot, and CopyPasteLearn.