Understanding Terraform Dynamic Blocks with Examples

Ever get a headache when going through a long Terraform configuration file filled with repetitive code? You may even find it difficult to understand what exactly is being provisioned and managed by Terraform. Your team may have ignored some best practices, probably due to tight deadlines, but now, the Terraform configuration files are becoming too difficult to understand and hard to maintain.

Well, you are in luck because dynamic blocks in Terraform can be helpful in such a scenario.

In this article, we’ll explore what Terraform dynamic blocks are, how to use them, and the best practices to follow.

Key Takeaways

  • Terraform dynamic blocks help you dynamically construct repeated nested blocks.
  • Dynamic blocks in Terraform are commonly used in the resource block. It is also supported inside the data, provider, and provisioner blocks
  • Dynamic blocks should not be overused as they will make your Terraform configuration hard to read and maintain.

Try the Terraform Provider Lab for free

Terraform Provider Lab
Terraform Provider Lab

What are Terraform Dynamic Blocks?

Some top-level block types, like the resource block, have arguments in the form of name = expression and also have nested blocks as arguments. Terraform dynamic blocks are commonly used to dynamically construct these repeated nested block arguments in resource-type blocks.

Terraform dynamic block type is supported inside resource, data, provider, and provisioner blocks. But it is commonly used in Resource blocks. Below is the syntax for creating Terraform dynamic blocks:

resource "resource_type" "resource_name" {
  # body of the resource block

  dynamic  "label"  {
    for_each = complex_value_to_iterate_over
    iterator = iterator_name

    content {
      # body of the dynamic block generated
    }
  }
}

Based on the syntax above, let’s explore what makes up the Terraform dynamic block:

  • label specifies the kind of repeated nested block to generate.
  • for_each specifies the complex value (common collections used are either list or map) to iterate over.
  • Iterator is optional. It specifies a name that represents the current element of the complex value being iterated over. if the iterator argument is not set, this defaults to the label of the dynamic block. Also, the iterator object has 2 properties:
    • key for a list is the list element index of the current element (e.g., 0, 1, or 2). For a map, it is the map’s key (e.g. {map_key: “map_value” }). Please note that for a set, the key and value are identical and should not be used - Terraform dynamic blocks are commonly used to iterate over lists or maps.
    • value for a list or map is the value of the current element.
  • labels is optional. It is used to specify additional labels for the generated blocks. Please note that for a dynamic block to have a labels argument, the block type in which the dynamic block is being used (e.g., resource block) must have a schema that includes a block type that expects the labels argument. It is rarely used in practice as most providers do not support this.
  • content is the body of each generated block. This contains the arguments of each dynamically constructed block. The iterator is accessed within this block to get the values of each dynamically generated block.

Want to learn more about Terraform's basic concepts? Check out this video:

Why We Need Terraform Dynamic Blocks?

Consider the trimmed-down code snippet below:

# main.tf
resource "aws_vpc" "sandbox_vpc" {
  cidr_block       = "10.0.0.0/16"
  instance_tenancy = "default"
  tags = {
    Name = "sandbox_vpc"
  }
}

resource "aws_subnet" "sandbox_subnet" {
  vpc_id     = aws_vpc.sandbox_vpc.id
  cidr_block = "10.0.1.0/24"

  tags = {
    Name = "sandbox_subnet"
  }
}

resource "aws_security_group" "sandbox_sg" {
  name   = "sandbox_sg"
  vpc_id = aws_vpc.sandbox_vpc.id

  ingress {
    description = "Allows SSH access"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.sandbox_vpc.cidr_block]
  }

  ingress {
    description = "Allows HTTP traffic"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.sandbox_vpc.cidr_block]
  }

  ingress {
    description = "Allows HTTPS traffic"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.sandbox_vpc.cidr_block]
  }

  # more ingress rules here...

  tags = {
    Name = "sandbox_sg"
  }
}

In this code, a Virtual Private Cloud which is a private network in AWS is provisioned. Also, within a VPC, there is a subnet that divides the VPC into subnetworks where there can be other resources like EC2 instances, for example. There is also a Security Group that serves as a firewall for the VPC. But within the Security Group resource (i.e., aws_security_group.sandbox_sg), there are lots of repeated ingress blocks that represent ingress rules. So, how can we refactor these into clean, organized, and maintainable code?

As seen in the trimmed-down code snippet above, the ingress blocks are the repeated nested blocks that would need to be dynamically produced using Terraform dynamic blocks for a cleaner and simpler codebase.

Now, let’s dive into some practical examples in the following sections. We’ll start out by refactoring the code above that has repeated nested ingress blocks in the security group resource (i.e., aws_security_group.sandbox_sg).

Example Usages of Terraform Dynamic Blocks

The examples in this section demonstrate how to use Terraform dynamic blocks.

Using Terraform Dynamic Block in Resource Blocks

The example below uses Terraform dynamic blocks to dynamically generate the repetitive ingress blocks instead of having several individual ingress blocks. It is a cleaner version of the code in the previous section.

# variables.tf
variable "settings" {
  type = list(object({
    description = string
    port        = number
  }))
  default = [
    {
      description = "Allows SSH access"
      port        = 22
    },
    {
      description = "Allows HTTP traffic"
      port        = 80
    },
    {
      description = "Allows HTTPS traffic"
      port        = 443
    }
  ]
}

# main.tf
resource "aws_vpc" "sandbox_vpc" {
  cidr_block       = "10.0.0.0/16"
  instance_tenancy = "default"

  tags = {
    Name = "sandbox_vpc"
  }
}

resource "aws_subnet" "sandbox_subnet" {
  vpc_id     = aws_vpc.sandbox_vpc.id
  cidr_block = "10.0.1.0/24"

  tags = {
    Name = "sandbox_subnet"
  }
}

resource "aws_security_group" "sandbox_sg" {
  name   = "sandbox_sg"
  vpc_id = aws_vpc.sandbox_vpc.id

  dynamic "ingress" {
    for_each = var.settings
    iterator = sandbox_sg_ingress

    content {
      description = sandbox_sg_ingress.value["description"]
      from_port   = sandbox_sg_ingress.value["port"]
      to_port     = sandbox_sg_ingress.value["port"]
      protocol    = "tcp"
      cidr_blocks = [aws_vpc.sandbox_vpc.cidr_block]
    }
  }

  tags = {
    Name = "sandbox_sg"
  }
}

Let’s take an in-depth look at the refactored code above:

  • The label of the dynamic block here is ingress. The ingress block is the repeated nested block that is now dynamically generated.
  • The complex value being iterated over using the for_each meta-argument is var.settings. This variable is a list of objects that contains the description and port for each dynamic block to be generated.
  • The iterator name is sandbox_sg_ingress. This is used to represent the current element of var.settings being iterated over. If the iterator name (i.e., sandbox_sg_ingress) is not specified, then the label of the dynamic block (i.e., ingress) will be used as the iterator.
  • The content contains the arguments of the generated ingress block. Please note that these arguments are attributes supported by the provider for the resource types. Also, note that the iterator is used within the content to access the values to be used in each dynamically generated block.

If the iterator is not specified, the dynamic block will be specified as shown below:

# main.tf
resource "aws_security_group" "sandbox_sg" {
  name   = "sandbox_sg"
  vpc_id = aws_vpc.sandbox_vpc.id

  dynamic "ingress" {
    for_each = var.settings

    content {
      description = ingress.value["description"]
      from_port   = ingress.value["port"]
      to_port     = ingress.value["port"]
      protocol    = "tcp"
      cidr_blocks = [aws_vpc.sandbox_vpc.cidr_block]
    }
  }

  tags = {
    Name = "sandbox_sg"
  }
}

Also, note that the content block can be rewritten as:

# main.tf
resource "aws_security_group" "sandbox_sg" {
  name   = "sandbox_sg"
  vpc_id = aws_vpc.sandbox_vpc.id

  dynamic "ingress" {
    for_each = var.settings

    content {
      description = ingress.value.description
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [aws_vpc.sandbox_vpc.cidr_block]
    }
  }

  tags = {
    Name = "sandbox_sg"
  }
}

Furthermore, note that the complex value used in the examples above is a list of objects (i.e., var.settings). For each iteration, we will have the following:

  • 1st iteration: ingress.key is 0 while ingress.value is {description = “Allow SSH access”, port = 22}
  • 2nd iteration: ingress.key is 1 while ingress.value is {description = “Allow HTTP traffic”, port = 80}
  • 3rd iteration: ingress.key is 2 while ingress.value is {description = “Allow HTTPS traffic”, port = 443}

The complex value iterated over by for_each in a Terraform dynamic block can also be a map of objects. Let’s see this in the example below:

# variables.tf
variable "settings" {
  type = map(object({
    port = number
  }))
  default = {
    "Allows SSH access" = {
      port = 22
    },
    "Allows HTTP traffic" = {
      port = 80
    },
    "Allows HTTPS traffic" = {
      port = 443
    }
  }
}

# main.tf
resource "aws_vpc" "sandbox_vpc" {
  cidr_block       = "10.0.0.0/16"
  instance_tenancy = "default"

  tags = {
    Name = "sandbox_vpc"
  }
}

resource "aws_subnet" "sandbox_subnet" {
  vpc_id     = aws_vpc.sandbox_vpc.id
  cidr_block = "10.0.1.0/24"

  tags = {
    Name = "sandbox_subnet"
  }
}

resource "aws_security_group" "sandbox_sg" {
  name   = "sandbox_sg"
  vpc_id = aws_vpc.sandbox_vpc.id

  dynamic "ingress" {
    for_each = var.settings

    content {
      description = ingress.key
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [aws_vpc.sandbox_vpc.cidr_block]
    }
  }

  tags = {
    Name = "sandbox_sg"
  }
}

In the code above, we iterate over a map of objects where the map keys represent the description and the value is port in the example given. For each iteration, we will have the following:

  • 1st iteration: ingress.key is “Allow SSH access” while ingress.value is {port = 22}
  • 2nd iteration: ingress.key is “Allow HTTP traffic” while ingress.value is {port = 80}
  • 3rd iteration: ingress.key is “Allow HTTPS traffic” while ingress.value is {port = 443}

Please note that a map is an unordered collection; hence the position of elements is not relevant.

Now, we have code that is organized, clean, and easy to maintain.

Using Terraform Dynamic Block in Data Blocks

Dynamic blocks are also supported in other blocks like data, for example:

# variables.tf
variable "instance_ids" {
  type    = set(string)
  default = ["i-0a279d13cce5fc103", "i-01be1b2f8dbce4a26", "i-07a5f9e413eabb5f1"]
}

variable "instance_tags" {
  type = list(object({
    name   = string
    values = list(string)
  }))
  default = [
    {
      name   = "tag:Environment"
      values = ["test"]
    },
    {
      name   = "instance-state-name"
      values = ["running"]
    }
  ]
}

variable "drive_size" {
  type    = number
  default = 6
}

# main.tf
data "aws_instance" "sandbox_server" {
  for_each    = var.instance_ids
  instance_id = each.value

  dynamic "filter" {
    for_each = var.instance_tags
    iterator = sandbox_server_filter

    content {
      name   = sandbox_server_filter.value["name"]
      values = sandbox_server_filter.value["values"]
    }
  }
}

resource "aws_ebs_volume" "sandbox_drive" {
  for_each          = data.aws_instance.sandbox_server
  availability_zone = data.aws_instance.sandbox_server[each.key].availability_zone
  size              = var.drive_size
}

resource "aws_volume_attachment" "sandbox_drive_attach" {
  for_each    = data.aws_instance.sandbox_server
  device_name = "/dev/sdb"
  volume_id   = aws_ebs_volume.sandbox_drive[each.key].id
  instance_id = data.aws_instance.sandbox_server[each.key].id
}

In the code above, we fetch EC2 instance details based on the requirements specified in the data block. We also use the Terraform dynamic block to dynamically generate the repeated nested filter block in the data block. Instead of having several repeated individual filter blocks written out literally based on all your desired requirements, we use dynamic blocks to keep the code clean and easy to read.

Please note that Terraform dynamic blocks cannot be used to generate meta-argument blocks like lifecycle and provisioner.

Best practices for Terraform Dynamic Blocks

It is recommended to use these blocks to abstract away repetitive and hard-to-read configurations when building a reusable module. It aims to provide a clean and simple user interface.

If using Terraform dynamic blocks makes your configuration files harder to comprehend, then you are better off writing the repeated nested blocks out literally.

Always keep in mind that the end goal is to have a simplified user interface that abstracts away complexity.

FAQ

Below are some of the frequently asked questions about Terraform dynamic blocks.

Can Terraform dynamic blocks be used for name = expression arguments?

Terraform dynamic blocks generate repeated nested blocks. They are used to only construct nested block arguments that are supported in a resource block type by that provider.

If you use it for a name = expression argument, you will get the error below:

Error: Unsupported block type

Hence, you can’t use Terraform dynamic blocks for name = expression arguments.

Can a Terraform dynamic block be inside another dynamic block?

Yes! You can have multi-level nested block structures if the resource types used have multiple levels of nested block within one another.

You can generate these nested structures by having another dynamic block within the content of a dynamic block.

Below is an example of a dynamic block inside another dynamic block:

dynamic "first_block" {
    for_each = complex_value_to_iterate_over
    content {
      first_block_arg = first_block.key

      dynamic "second_block" {
        for_each = first_block.value.first_block_attribute
        content {
          second_block_arg = second_block.value.second_block_attribute
        }
      }
    }
  }

In the code above, we have second_block nested inside first_block within the content portion of the dynamic block. Also, note that first_block.value.first_block_attribute refers to the current element of the outer dynamic block while second_block.value.second_block_attribute refers to the current element of the inner dynamic block.

The outer dynamic block generates repeated nested first_block, as we have seen earlier. This nested first_block has a nested block argument called second_block, and within this inner block, you can reference either the current element of the outer block or the current element of the inner block.

Please note that while this is a great feature of Terraform dynamic blocks, it could also lead to code that is difficult to understand.

Conclusion

You now understand what Terraform dynamic blocks are and have seen practical examples of how to use them. You still need to ensure they are used appropriately to avoid making your code hard to understand.

Are you looking to polish your Terraform skills in a real-world environment? Enroll in our Terraform for Beginners Course, which covers all of Terraform fundamentals. It includes video lectures, interactive exercises, and hands-on labs to help you internalize concepts and commands.

If you want to gain other Infrastructure as Code (IaC) skills, check out our IaC Learning Path.