Advanced Terraform

Dynamic Blocks & Expressions

● Intermediate ⏱ 20 min read terraform

Some resource types have nested blocks — ingress rules in a security group, environment variables in an ECS task, lifecycle rules in an S3 bucket — that you need to repeat based on a variable-length list. Hardcoding each block is fragile. Dynamic blocks let you generate them from a collection. Combined with Terraform's expression language — for expressions, conditionals, splat operators, and string templates — you can write configurations that are both concise and flexible.

Dynamic Blocks

A dynamic block generates repeated nested blocks from a collection. The syntax wraps any nested block type:

resource "aws_security_group" "app" {
  name   = "app-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
    }
  }
}

With 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"] },
    { from_port = 22,  to_port = 22,  protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] },
  ]
}

Terraform generates one ingress block per list element. Inside the content block, ingress.value refers to the current element (the iterator name matches the dynamic block label).

Compare this to the equivalent without dynamic blocks — if you had 10 ingress rules, you'd write 10 identical-but-different blocks. Adding a new rule means editing the config. With dynamic blocks, adding a rule means adding one element to a variable.

Custom Iterator Name

By default, the iterator variable shares the name of the block type. When this conflicts with another variable or when nesting dynamic blocks, set a custom name with iterator:

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

  dynamic "ingress" {
    for_each = var.ingress_rules
    iterator = rule   # rename the iterator
    content {
      from_port   = rule.value.from_port
      to_port     = rule.value.to_port
      protocol    = rule.value.protocol
      cidr_blocks = rule.value.cidr_blocks
    }
  }
}

Inside the content block, rule.key is the index (for lists) or key (for maps) and rule.value is the element.

Nested Dynamic Blocks

Dynamic blocks can nest — useful for resources like ALB listener rules that have nested conditions:

resource "aws_lb_listener_rule" "routing" {
  listener_arn = aws_lb_listener.front.arn
  priority     = 100

  dynamic "condition" {
    for_each = var.conditions
    content {
      dynamic "path_pattern" {
        for_each = condition.value.path_patterns
        content {
          values = [path_pattern.value]
        }
      }

      dynamic "host_header" {
        for_each = condition.value.host_headers
        content {
          values = condition.value.host_headers
        }
      }
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}
⚠️
Dynamic blocks reduce readability

A configuration where every nested block is dynamic is hard to audit — the plan output is generated, not textual. Prefer static blocks when the count is small and fixed. Use dynamic blocks only when the count genuinely varies at runtime.

for Expressions

for expressions transform collections — lists, maps, or sets — into new collections. They're the Terraform equivalent of a map/filter operation.

List to list

# Uppercase all strings in a list
variable "names" {
  default = ["alice", "bob", "carol"]
}

locals {
  upper_names = [for n in var.names : upper(n)]
  # Result: ["ALICE", "BOB", "CAROL"]
}

List to map

variable "users" {
  type = list(object({ name = string, role = string }))
}

locals {
  user_roles = { for u in var.users : u.name => u.role }
  # Result: { "alice" = "admin", "bob" = "viewer" }
}

Filtering with if

variable "instances" {
  type = list(object({ id = string, env = string }))
}

locals {
  prod_ids = [for i in var.instances : i.id if i.env == "prod"]
}

Map to list

variable "tags" {
  type    = map(string)
  default = { env = "prod", team = "platform" }
}

locals {
  tag_lines = [for k, v in var.tags : "${k}=${v}"]
  # Result: ["env=prod", "team=platform"]
}

When iterating a map, the for expression receives both key and value: for k, v in map.

Conditional Expressions

The ternary operator selects between two values based on a boolean condition:

variable "environment" {
  type    = string
  default = "prod"
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
}

Conditionals are also used to make optional attributes return null, which Terraform treats as "omit this attribute":

resource "aws_db_instance" "main" {
  # Only set multi_az in production
  multi_az = var.environment == "prod" ? true : null

  # Only set a final snapshot identifier in production
  final_snapshot_identifier = var.environment == "prod" ? "prod-final-snapshot" : null
  skip_final_snapshot       = var.environment == "prod" ? false : true
}

Splat Expressions

Splat expressions extract an attribute from each element in a list. They're shorthand for a for expression that collects one attribute:

resource "aws_instance" "web" {
  count         = 3
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}

# Splat: collect all private IPs
output "web_ips" {
  value = aws_instance.web[*].private_ip
  # Equivalent: [for i in aws_instance.web : i.private_ip]
}

The [*] operator works on lists and sets. For for_each resources (which produce maps), use values() first:

resource "aws_s3_bucket" "store" {
  for_each = var.buckets
  bucket   = "myapp-${each.key}"
}

output "bucket_arns" {
  value = values(aws_s3_bucket.store)[*].arn
}

String Templates

Terraform's %{} syntax enables loops and conditionals inside strings — useful for generating scripts or configuration files:

variable "servers" {
  default = ["web-1", "web-2", "web-3"]
}

output "hosts_file" {
  value = <<-EOT
    %{ for s in var.servers ~}
    10.0.0.${index(var.servers, s) + 1} ${s}
    %{ endfor ~}
  EOT
}

The ~ after %{} strips trailing whitespace and newlines, keeping output clean. Conditional directives:

output "greeting" {
  value = "Hello, ${var.name != "" ? var.name : "world"}!"
}

output "config_block" {
  value = <<-EOT
    server_name ${var.hostname};
    %{ if var.ssl_enabled ~}
    ssl on;
    ssl_certificate /etc/ssl/cert.pem;
    %{ endif ~}
  EOT
}

Key Takeaways