Terraform Dynamic Block

Bits Lovers
Written by Bits Lovers on
Terraform Dynamic Block

Terraform lets you manage a lot of infrastructure declaratively, but sometimes you need to repeat the same nested block configuration multiple times – with slight variations. That’s where dynamic blocks come in.

If you’ve used count before, you already know it has limits. Dynamic blocks solve a different problem that count can’t touch.

What is a Terraform Dynamic Block?

Think of a dynamic block like a for expression, but instead of producing a value, it produces nested configuration blocks. It’s one of those Terraform features that flies under the radar – most tutorials barely mention it – but it handles a real gap in the language.

Dynamic blocks exist to solve a specific problem: some resource attributes accept multiple nested blocks, but Terraform doesn’t let you use count or for inside a resource block to generate them dynamically. Dynamic blocks fill that gap.

Where Do Dynamic Blocks Work?

Dynamic blocks can only go inside other blocks, and only where the resource provider actually supports repeatable nested configurations. Not every resource attribute accepts them – Terraform’s own documentation notes this limitation explicitly.

The most common use cases are adding multiple tags to a resource or defining security group rules. Both involve repeating the same block structure with different values, which is exactly what dynamic blocks were made for.

Dynamic Block Syntax

Here’s the basic shape:

dynamic "my_setting" {
  for_each = VARIABLE_NAME  # set | map | list
  content = {
    key = my_setting.value
  }
}

my_setting is a temporary variable that holds the current item during each iteration. You can rename it using the iterator argument if you want something clearer:

dynamic "ingress" {
  for_each = var.ports
  iterator = port
  content {
    from_port = port.value
    to_port   = port.value
    protocol  = "tcp"
  }
}

VARIABLE_NAME can be a set, map, or list. Inside content, you access the current item via the iterator variable – use .value for the value and .key for the map key (or list index if iterating a list).

List vs. Map with for_each

With a list, for_each gives you a zero-based index as the key and the list item as the value. With a map, you get the map key and map value directly. This affects how you reference things inside the content block, so keep it straight when you write your code.

A Real Example: Dynamic Tags on EC2

Say you want to attach tags to an EC2 instance, and you want those tags to come from a variable so you can reuse the same configuration across environments.

data "aws_ami" "os" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}

resource "aws_instance" "server" {
  ami           = data.aws_ami.os.id
  instance_type = "t3.large"

  tags = {
    Project = "Hello BitsLovers!"
  }

  dynamic "tag" {
    for_each = var.append_custom_tags

    content {
      key   = tag.key
      value = tag.value
    }
  }
}

Running terraform plan shows Terraform generating each tag block in sequence. If append_custom_tags is empty, only the static “Project” tag gets created.

Combining for_each with a for Expression

You can chain a for expression inside for_each to filter or transform values on the fly:

dynamic "tag" {
  for_each = {
    for key, value in var.append_custom_tags:
    key => lower(value)
    if key != "Project"
  }

  content {
    key   = tag.key
    value = tag.value
  }
}

This loops over append_custom_tags, lowercases every value, and skips any key named “Project”. You can layer in any logic your use case needs.

Example: Environment Variables in Kubernetes

Another common pattern is passing environment variables to a container. If you have env_vars as a list, you can iterate over it with a dynamic block:

resource "kubernetes_deployment" "bits_server" {
  metadata {
    name = "Terraform Tutorial - Dynamic Blocks"
    labels = {
      test = "BitsLoversApp"
    }
  }

  spec {
    replicas = 2

    selector {
      match_labels = {
        test = "BitsLoversApp"
      }
    }

    template {
      metadata {
        labels = {
          test = "BitsLoversApp"
        }
      }

      spec {
        container {
          image = "nginx:1.27"
          name  = "example"

          dynamic "env" {
            for_each = var.env_vars
            content {
              name  = env.value.name
              value = env.value.value
            }
          }

          resources {
            limits = {
              cpu    = "0.5"
              memory = "512Mi"
            }
            requests = {
              cpu    = "250m"
              memory = "50Mi"
            }
          }

          liveness_probe {
            http_get {
              path = "/"
              port = 443

              http_header {
                name  = "X-Custom-Header"
                value = "Custom value"
              }
            }
          }
        }
      }
    }
  }
}

Note that since env_vars is a list, we use env.value.name and env.value.value – not env.key.

The Classic Example: Security Groups

Here’s where dynamic blocks really shine. A security group with five ingress rules looks like this without them:

resource "aws_security_group" "demo_sg" {
  name = "sample-sg"

  ingress {
    from_port   = 8080
    to_port     = 8080
    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   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 21
    to_port     = 21
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Verbose, repetitive, and hard to maintain. Now here’s the same thing with a dynamic block:

variable "sg_ports" {
  type        = list(number)
  description = "list of ingress ports"
  default     = [8080, 80, 21, 22, 443]
}

resource "aws_security_group" "dynamicsg" {
  name        = "dynamic-sg"
  description = "Ingress for Vault"

  dynamic "ingress" {
    for_each = var.sg_ports
    iterator = port
    content {
      from_port   = port.value
      to_port     = port.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }

  dynamic "egress" {
    for_each = var.sg_ports
    content {
      from_port   = egress.value
      to_port     = egress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

Same result, one-third the lines.

When NOT to Use Dynamic Blocks

Dynamic blocks solve real problems, but they’re easy to overuse. If a dynamic block makes your configuration harder to read at a glance, you’re probably better off with repetition or a module. Terraform’s own documentation recommends using dynamic blocks only when necessary.

One hard limitation: dynamic blocks can only generate arguments that belong to the same resource type, data source, provider, or provisioner being configured. Meta-arguments like lifecycle and provisioner blocks cannot be created dynamically – Terraform processes those before it evaluates expressions.

Also worth knowing: the Terraform AWS provider has deprecated inline ingress and egress blocks on aws_security_group in favor of separate aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources. If you’re writing new security group code, consider using those instead.

Bits Lovers

Bits Lovers

Professional writer and blogger. Focus on Cloud Computing.

Comments

comments powered by Disqus