How to use dynamic block in Terraform | Brainboard Blog

How to use dynamic block in Terraform

Chafik Belhaoues September 4, 2025
Expert reviewed
How to use dynamic block in Terraform
How to use dynamic block in TerraformDevOps2025-09-04T00:00:00.000Z12 minutesintermediateguidedevelopers, DevOps engineers, cloud architects
12 min read
intermediate
guide

How to use dynamic block in Terraform

Have you ever found yourself writing the same Terraform configuration blocks over and over again, wishing there was a better way? If you’ve been working with Terraform for a while, you’ve probably encountered situations where you need to create multiple similar nested blocks based on a variable or conditional logic. That’s where dynamic blocks come to the rescue – they’re like the Swiss Army knife of Terraform configurations, making your infrastructure as code more flexible and maintainable.

What are dynamic blocks and why should you care?

Think of dynamic blocks as your personal assistant that can generate configuration blocks on the fly. Instead of hardcoding multiple similar blocks, you can use a single dynamic block that iterates over a collection and creates the necessary configuration automatically. It’s like having a template that adapts to your needs – pretty neat, right?

In traditional Terraform configurations, when you need multiple similar blocks, you’d typically write them out one by one. This approach works, but it’s about as efficient as writing the same email to 20 different people manually instead of using a mail merge. Dynamic blocks solve this problem by allowing you to generate nested blocks programmatically, making your code DRY (Don’t Repeat Yourself) and much easier to maintain.

Understanding the anatomy of a dynamic block

Before we dive into the practical examples, let’s break down how a dynamic block actually works. The basic syntax might look a bit intimidating at first, but once you get the hang of it, it becomes second nature.

dynamic "block_type" {
  for_each = var.some_collection
  content {
    # Configuration for each block
    property = block_type.value
  }
}

The structure consists of three main components that work together like gears in a machine. First, you have the block_type, which specifies what kind of nested block you’re creating. Then there’s for_each, which takes a collection (list, set, or map) to iterate over. Finally, the content block defines what goes inside each generated block.

Common use cases for dynamic blocks

Security group rules in AWS

One of the most common scenarios where dynamic blocks shine is when configuring security group rules. Let’s say you need to allow traffic from multiple IP ranges to your EC2 instances. Instead of writing individual ingress rules, you can use a dynamic block:

variable "allowed_ips" {
  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 = ["10.0.0.0/8"]
    },
    {
      from_port   = 443
      to_port     = 443
      protocol    = "tcp"
      cidr_blocks = ["10.0.0.0/8", "192.168.0.0/16"]
    }
  ]
}

resource "aws_security_group" "example" {
  name        = "example-sg"
  description = "Example security group with dynamic rules"

  dynamic "ingress" {
    for_each = var.allowed_ips
    content {
      from_port   = ingress.value.from_port
      to_port     = ingress.value.to_port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

This approach is much cleaner than writing multiple ingress blocks manually, wouldn’t you agree? Plus, if you need to add or remove rules, you just update the variable – no need to touch the resource configuration itself.

Container definitions in ECS

Another fantastic use case is when working with AWS ECS task definitions. You might need to define multiple containers with different configurations:

variable "containers" {
  type = list(object({
    name   = string
    image  = string
    memory = number
    ports  = list(number)
  }))
}

resource "aws_ecs_task_definition" "app" {
  family                   = "my-app"
  requires_compatibilities = ["FARGATE"]
  network_mode            = "awsvpc"
  cpu                     = "256"
  memory                  = "512"

  container_definitions = jsonencode([
    for container in var.containers : {
      name      = container.name
      image     = container.image
      memory    = container.memory
      essential = true
      portMappings = [
        for port in container.ports : {
          containerPort = port
          protocol      = "tcp"
        }
      ]
    }
  ])
}

Advanced techniques with dynamic blocks

Conditional dynamic blocks

Sometimes you want to create blocks only when certain conditions are met. You can achieve this by using conditional expressions with your for_each argument:

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

variable "log_buckets" {
  type = list(string)
  default = ["bucket1", "bucket2"]
}

resource "aws_s3_bucket" "example" {
  bucket = "my-example-bucket"

  dynamic "logging" {
    for_each = var.enable_logging ? var.log_buckets : []
    content {
      target_bucket = logging.value
      target_prefix = "logs/"
    }
  }
}

This pattern is incredibly useful when you want to make certain features optional in your infrastructure. It’s like having a switch that can turn features on or off based on your environment or requirements.

Nested dynamic blocks

Here’s where things get really interesting – you can nest dynamic blocks within other dynamic blocks. It’s like inception, but for infrastructure code:

variable "load_balancer_rules" {
  type = list(object({
    priority = number
    conditions = list(object({
      field  = string
      values = list(string)
    }))
    target_group_arn = string
  }))
}

resource "aws_lb_listener_rule" "example" {
  listener_arn = aws_lb_listener.main.arn

  dynamic "action" {
    for_each = var.load_balancer_rules
    content {
      type             = "forward"
      target_group_arn = action.value.target_group_arn
    }
  }

  dynamic "condition" {
    for_each = var.load_balancer_rules
    content {
      dynamic "host_header" {
        for_each = [for c in condition.value.conditions : c if c.field == "host-header"]
        content {
          values = host_header.value.values
        }
      }
    }
  }
}

Best practices and common pitfalls

When working with dynamic blocks, there are several best practices you should keep in mind to avoid common mistakes that can make your code harder to understand or debug.

First, always use meaningful variable names. Instead of using generic names like item or element, use descriptive names that indicate what the block represents. This makes your code self-documenting and easier for your team members (or future you) to understand.

Second, be careful with the complexity of your dynamic blocks. While it’s tempting to create highly sophisticated nested structures, remember that readability should be your priority. If a dynamic block becomes too complex, consider breaking it down into smaller, more manageable pieces or using local values to prepare your data.

Here’s an example of how to improve readability with local values:

locals {
  # Prepare and transform data before using in dynamic blocks
  formatted_rules = [
    for rule in var.security_rules : {
      from_port   = rule.port
      to_port     = rule.port
      protocol    = rule.protocol
      cidr_blocks = rule.allow_from
      description = "Allow ${rule.protocol} on port ${rule.port}"
    }
  ]
}

resource "aws_security_group" "main" {
  name = "main-sg"

  dynamic "ingress" {
    for_each = local.formatted_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 = ingress.value.description
    }
  }
}

Debugging dynamic blocks

Let’s face it – debugging dynamic blocks can sometimes feel like finding a needle in a haystack. But don’t worry, I’ve got some tricks up my sleeve that’ll make your life easier.

The terraform console command is your best friend when debugging dynamic blocks. You can test your expressions and see exactly what data is being generated:

terraform console
> var.allowed_ips
> [for rule in var.allowed_ips : rule.from_port]

Another helpful technique is to use the terraform plan command with the -out flag to save your plan to a file, then inspect it carefully to see what resources and blocks are being created:

terraform plan -out=tfplan
terraform show -json tfplan | jq '.planned_values'

Performance considerations

While dynamic blocks are powerful, they do come with some performance implications you should be aware of. Each time Terraform runs a plan or apply, it needs to evaluate all the dynamic blocks and their iterations. If you’re working with large datasets, this can slow down your Terraform operations.

To optimize performance, consider these strategies. First, use local values to pre-compute complex transformations instead of doing them inline within the dynamic block. Second, if you’re dealing with very large collections, consider whether all items really need to be in a single resource or if they could be split across multiple resources.

Real-world example: Building a flexible network architecture

Let’s put everything together with a real-world example that showcases the power of dynamic blocks. We’ll create a flexible network architecture that can adapt to different environments:

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

variable "network_config" {
  type = map(object({
    vpc_cidr = string
    subnets = list(object({
      cidr = string
      az   = string
      type = string # "public" or "private"
    }))
    enable_nat_gateway = bool
    enable_vpn_gateway = bool
  }))
  default = {
    dev = {
      vpc_cidr = "10.0.0.0/16"
      subnets = [
        { cidr = "10.0.1.0/24", az = "us-west-2a", type = "public" },
        { cidr = "10.0.2.0/24", az = "us-west-2b", type = "public" },
        { cidr = "10.0.10.0/24", az = "us-west-2a", type = "private" },
        { cidr = "10.0.11.0/24", az = "us-west-2b", type = "private" }
      ]
      enable_nat_gateway = false
      enable_vpn_gateway = false
    }
    prod = {
      vpc_cidr = "10.1.0.0/16"
      subnets = [
        { cidr = "10.1.1.0/24", az = "us-west-2a", type = "public" },
        { cidr = "10.1.2.0/24", az = "us-west-2b", type = "public" },
        { cidr = "10.1.3.0/24", az = "us-west-2c", type = "public" },
        { cidr = "10.1.10.0/24", az = "us-west-2a", type = "private" },
        { cidr = "10.1.11.0/24", az = "us-west-2b", type = "private" },
        { cidr = "10.1.12.0/24", az = "us-west-2c", type = "private" }
      ]
      enable_nat_gateway = true
      enable_vpn_gateway = true
    }
  }
}

locals {
  current_config = var.network_config[var.environment]
  public_subnets = [for s in local.current_config.subnets : s if s.type == "public"]
  private_subnets = [for s in local.current_config.subnets : s if s.type == "private"]
}

resource "aws_vpc" "main" {
  cidr_block = local.current_config.vpc_cidr

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

resource "aws_subnet" "public" {
  count                   = length(local.public_subnets)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = local.public_subnets[count.index].cidr
  availability_zone       = local.public_subnets[count.index].az
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.environment}-public-subnet-${count.index + 1}"
    Type = "public"
  }
}

resource "aws_subnet" "private" {
  count             = length(local.private_subnets)
  vpc_id            = aws_vpc.main.id
  cidr_block        = local.private_subnets[count.index].cidr
  availability_zone = local.private_subnets[count.index].az

  tags = {
    Name = "${var.environment}-private-subnet-${count.index + 1}"
    Type = "private"
  }
}

resource "aws_nat_gateway" "main" {
  count         = local.current_config.enable_nat_gateway ? length(local.public_subnets) : 0
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "${var.environment}-nat-gateway-${count.index + 1}"
  }
}

resource "aws_eip" "nat" {
  count  = local.current_config.enable_nat_gateway ? length(local.public_subnets) : 0
  domain = "vpc"

  tags = {
    Name = "${var.environment}-nat-eip-${count.index + 1}"
  }
}

Conclusion

Dynamic blocks in Terraform are a game-changer when it comes to writing clean, maintainable, and flexible infrastructure code. They transform repetitive configurations into elegant, programmatic solutions that adapt to your needs. By mastering dynamic blocks, you’re not just writing better Terraform code – you’re building infrastructure that can evolve with your requirements without constant rewrites.

Remember, the key to success with dynamic blocks is finding the right balance between flexibility and readability. Start simple, test thoroughly, and gradually increase complexity as you become more comfortable with the patterns. Your future self (and your team) will thank you for writing infrastructure code that’s both powerful and maintainable.

FAQs

Can I use dynamic blocks with all Terraform resource types?

Not all resource arguments support dynamic blocks. Dynamic blocks can only be used for resource arguments that accept nested configuration blocks, such as ingress rules in security groups or setting blocks in various AWS resources. Simple attributes that accept single values or lists of strings cannot use dynamic blocks.

What’s the difference between count/for_each and dynamic blocks?

Count and for_each are used to create multiple instances of entire resources, while dynamic blocks create multiple nested blocks within a single resource. Use count/for_each when you need multiple separate resources, and use dynamic blocks when you need multiple configuration blocks within one resource.

Can I reference other dynamic blocks within the same resource?

No, you cannot directly reference one dynamic block from another within the same resource. Each dynamic block operates independently. If you need to share data between blocks, prepare it using local values before the resource definition.

How do I handle empty collections in dynamic blocks?

When the collection in for_each is empty, no blocks are generated. This is actually a feature – you can use conditional expressions like var.create_rules ? var.rules : [] to conditionally create blocks based on boolean flags.

Is there a performance impact when using many dynamic blocks?

Yes, complex dynamic blocks with large datasets can impact Terraform’s planning time. Each iteration needs to be evaluated during the plan phase. For better performance, pre-process data using locals and avoid deeply nested dynamic blocks when possible. Consider splitting very large configurations across multiple resources if performance becomes an issue.

Back to Blog