In the previous post, we established loop fundamentals. The loops were pretty basic, though. We were only able to assign a single attribute for each resource per iteration. In this post, we’ll learn how to assign multiple attributes per iteration.

As a part of that, we’ll cover another Terraform looping construct, the dynamic nested block. The dynamic nested block provides a way to build repeated nested configuration blocks. This construct works at the attribute level. We’ll start with simple examples and work our way up to more complex examples.

Nested Configuration Block

First, let’s cover what a nested configuration block is. A security group with ingress rules provides a simple example:

resource "aws_security_group" "simple" {
  name        = "demo-simple"
  description = "demo-simple"

  ingress {
    description = "description 0"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    description = "description 1"
    from_port   = 81
    to_port     = 81
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

The code creates a security group with 2 security group rules. The ingress attribute is repeated multiple times with different blocks of code. Some resource attributes can be assigned with this configuration block DSL syntax.

Expand to see terraform apply results.
$ terraform apply
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

simple = {
  "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0e53736807a424c35"
  "description" = "demo-simple"
  "egress" = []
  "id" = "sg-0e53736807a424c35"
  "ingress" = [
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 0"
      "from_port" = 80
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 80
    },
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 1"
      "from_port" = 81
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 81
    },
  ]
  "name" = "demo-simple"
  "name_prefix" = ""
  "owner_id" = "111111111111"
  "revoke_rules_on_delete" = false
  "vpc_id" = "vpc-11111111"
}

Dynamic Nested Block Intro

Dynamic nested blocks can be used to assign multiple attributes. Here’s the first example re-written with a dynamic block.

locals {
  ports = [80, 81]
}
resource "aws_security_group" "dynamic" {
  name        = "demo-dynamic"
  description = "demo-dynamic"

  dynamic "ingress" {
    for_each = local.ports
    content {
      description = "description ${ingress.key}"
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}
Expand to see terraform apply results.
$ terraform apply
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

dynamic = {
  "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-08b33d072c13b71b9"
  "description" = "demo-dynamic"
  "egress" = []
  "id" = "sg-08b33d072c13b71b9"
  "ingress" = [
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 0"
      "from_port" = 80
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 80
    },
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 1"
      "from_port" = 81
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 81
    },
  ]
  "name" = "demo-dynamic"
  "name_prefix" = ""
  "owner_id" = "111111111111"
  "revoke_rules_on_delete" = false
  "vpc_id" = "vpc-11111111"
}

It produces the same previous hardcoded result. First, let’s point out a few things initial things about this code:

  • The dynamic argument is the original attribute we declared with a configuration block: “ingress”
  • A for_each assignment is used.
  • The content block contains the original “ingress” block.

Now, let’s cover the more confusing portions:

  • Terraform magically provides an ingress object. The object name matches the dynamic argument “ingress”.
  • The ingress object is a wrapper iterator object that contains info for each element that was assigned with for_each = local.ports.

In other words, the ingress object will have these values for each iteration or pass of the loop:

Iteration Values
1 ingress.key = 0 and ingress.value = 80
2 ingress.key = 1 and ingress.value = 81

We’re using a simple list(string) structure for local.port to help understand exactly what values Terraform magically assigns to the ingress wrapper iterator object. It will also help us understand what is in ingress.value when we make it more complicated and useful in the next section.

Useful Dynamic Block with Attrs List

Let’s make the dynamic nested block in the security group code more useful. Instead of hardcoding 0.0.0.0/0 for the rule, we’ll also make that dynamic.

To achieve this, the locals input data structure needs to be more complex. We’ll change it to a List of Maps. Each Map stores the ingress rule attributes.

locals {
  rules = [{
    description = "description 0",
    port = 80,
    cidr_blocks = ["0.0.0.0/0"],
  },{
    description = "description 1",
    port = 81,
    cidr_blocks = ["10.0.0.0/16"],
  }]
}
resource "aws_security_group" "attrs" {
  name        = "demo-attrs"
  description = "demo-attrs"

  dynamic "ingress" {
    for_each = local.rules
    content {
      description = ingress.value.description
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}
Expand to see terraform apply results.
$ terraform apply
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

list = {
  "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-05c844b0799ff1f47"
  "description" = "demo-list"
  "egress" = []
  "id" = "sg-05c844b0799ff1f47"
  "ingress" = [
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 0"
      "from_port" = 80
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 80
    },
    {
      "cidr_blocks" = [
        "10.0.0.0/16",
      ]
      "description" = "description 1"
      "from_port" = 81
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 81
    },
  ]
  "name" = "demo-list"
  "name_prefix" = ""
  "owner_id" = "111111111111"
  "revoke_rules_on_delete" = false
  "vpc_id" = "vpc-11111111"
}

This example is much more useful. Pretty much any value assigned by the ingress block can now be configured.

Remember, the ingress object is a wrapper object described in the previous example. The ingress.value unravels the wrapper object and contains each element of the List, which is a Map. The ingress.key was not used because it contains the index number and is not very useful.

Here’s a table with the iterations to help explain again:

Iteration Values
1 ingress.key = 0 and ingress.value = {description = "description 0", port = 80, cidr_blocks = ["0.0.0.0/0"]
2 ingress.key = 1 and ingress.value = {description = "description 1", port = 81, cidr_blocks = ["10.0.0.0/16"]

Useful Dynamic Block with Attrs Map

locals {
  map = {
    "description 0" = {
      port = 80,
      cidr_blocks = ["0.0.0.0/0"],
    }
    "description 1" = {
      port = 81,
      cidr_blocks = ["10.0.0.0/16"],
    }
  }
}
resource "aws_security_group" "map" {
  name        = "demo-map"
  description = "demo-map"

  dynamic "ingress" {
    for_each = local.map
    content {
      description = ingress.key # IE: "description 0"
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}
output "map" {
  value = aws_security_group.map
}
Expand to see terraform apply results.
$ terraform apply
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

map = {
  "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-00a81d1d9ab6f08ae"
  "description" = "demo-map"
  "egress" = []
  "id" = "sg-00a81d1d9ab6f08ae"
  "ingress" = [
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 0"
      "from_port" = 80
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 80
    },
    {
      "cidr_blocks" = [
        "10.0.0.0/16",
      ]
      "description" = "description 1"
      "from_port" = 81
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 81
    },
  ]
  "name" = "demo-map"
  "name_prefix" = ""
  "owner_id" = "111111111111"
  "revoke_rules_on_delete" = false
  "vpc_id" = "vpc-11111111"
}

In this case, ingress.key is used because it contains the description. Here’s a table with the iterations to help explain again:

Iteration Values
1 ingress.key = "description 0" and ingress.value = {port = 80, cidr_blocks = ["0.0.0.0/0"]
2 ingress.key = "description 1" and ingress.value = {port = 81, cidr_blocks = ["10.0.0.0/16"]

For Each Difference: Resource vs Dynamic Block Level

In the previous post, we covered how to use the for_each attribute at the resource level to perform looping. In this post, we’re covering for_each at the dynamic nested block level. Even though they may look the same and have the same name, they are different.

With the resource-level for_each:

  • The for_each argument must be a map or set of strings.
  • The magical object is each. This magical object is an iterator wrapper object. To access the direct object, you use .key and .value.
  • When iterating over a Map, the iterator’s .key contains the Map key.
  • When iterating over a Set, the iterator’s .key and .value are the same.

With the dynamic nested block for_each:

  • The for_each argument is requirements are more relaxed. It’s not strictly required to be a map or set of Strings.
  • The magical object name corresponds to the original configuration name, IE: ingress. This magical object is an iterator wrapper object. To access the direct object, you use .key and .value.
  • When iterating over a List, the iterator’s .key contains the index.
  • When iterating over a Map, the iterator’s .key contains the Map key.

As mentioned, when using dynamic nested block for_each the magic variable is not named each. We can set the wrapper iterator object name with iterator, though. This is useful when the name of the attribute is long and want to shorten it. Example:

locals {
  map = {
    "description 0" = {
      port = 80,
      cidr_blocks = ["0.0.0.0/0"],
    }
    "description 1" = {
      port = 81,
      cidr_blocks = ["10.0.0.0/16"],
    }
  }
}
resource "aws_security_group" "map" {
  name        = "demo-map"
  description = "demo-map"

  dynamic "ingress" {
    for_each = local.map
    # normally would be "ingress" here, but we're overriding the name
    iterator = each
    content {
      # now we use each. instead of ingress.
      description = each.key # IE: "description 0"
      from_port   = each.value.port
      to_port     = each.value.port
      protocol    = "tcp"
      cidr_blocks = each.value.cidr_blocks
    }
  }
}
Expand to see terraform apply results.
$ terraform apply
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

map = {
  "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0d971b9430b6de78a"
  "description" = "demo-map"
  "egress" = []
  "id" = "sg-0d971b9430b6de78a"
  "ingress" = [
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 0"
      "from_port" = 80
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 80
    },
    {
      "cidr_blocks" = [
        "10.0.0.0/16",
      ]
      "description" = "description 1"
      "from_port" = 81
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 81
    },
  ]
  "name" = "demo-map"
  "name_prefix" = ""
  "owner_id" = "111111111111"
  "revoke_rules_on_delete" = false
  "vpc_id" = "vpc-11111111"
}

Configuration Blocks Are Syntactical Sugar

It is useful to understand that the configuration block syntax ability is syntactical sugar. Essentially, we can also assign the attribute directly with a List of Maps. Example:

resource "aws_security_group" "direct" {
  name        = "demo-direct"
  description = "demo-direct"

  ingress = [{
    description = "description 0"
    from_port   = "80"
    to_port     = "80"
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    ipv6_cidr_blocks = null
    prefix_list_ids = null
    security_groups = null
    self = null
  },{
    description = "description 1"
    from_port   = "81"
    to_port     = "81"
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    ipv6_cidr_blocks = null
    prefix_list_ids = null
    security_groups = null
    self = null
  }]
}
Expand to see terraform apply results.
$ terraform apply
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

direct = {
  "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-00b95d25975196bf9"
  "description" = "demo-direct"
  "egress" = []
  "id" = "sg-00b95d25975196bf9"
  "ingress" = [
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 0"
      "from_port" = 80
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 80
    },
    {
      "cidr_blocks" = [
        "0.0.0.0/0",
      ]
      "description" = "description 1"
      "from_port" = 81
      "ipv6_cidr_blocks" = []
      "prefix_list_ids" = []
      "protocol" = "tcp"
      "security_groups" = []
      "self" = false
      "to_port" = 81
    },
  ]
  "name" = "demo-direct"
  "name_prefix" = ""
  "owner_id" = "111111111111"
  "revoke_rules_on_delete" = false
  "vpc_id" = "vpc-11111111"
}

Here ingress is directly assigned with the equal sign. With direct assignment, we are setting raw values, so we must also assign extra attributes like ipv6_cidr_blocks and prefix_list_ids. When using the syntactical sugar version, defaults are set for us. Understanding that nested configuration blocks can be assigned directly is key information to know for the next blog post.

Summary

In this post, we covered how to use the dynamic nested block loop construct. Terraform performs a bit of magic by making an iterator wrapper object available that matches the configuration block attribute’s original name. You can unravel this wrapper object with the .value method. Understanding this will make it easier to use dynamic blocks with Terraform. In the next post, we’ll cover more complex loops.

The source code for the examples is available at: terraform-hcl-tutorials/5-dynamic-blocks

Want It to be Easier to Work with Terraform?

Check out Terraspace: The Terraform Framework.

The Terraform HCL Language Intro Tutorials