In this post, we’ll take on nested loops with Terraform. Terraform is declarative, so a nested loop can be tricky. This post hopes to help with that.

Previous Posts Review

We’ve covered loops fundamentals in the previous two blog posts:

We’re building on top of those learnings, so if you have not read those posts yet, it’ll be helpful to go back and understand those posts.

Resource Loop Starter Code

First, we’ll create 2 security groups with a for_each loop at the resource-level using what we learned from: Terraform Intro 4: Loops with Count and For Each.

locals {
  names = ["demo-example-1", "demo-example-2"]
}
resource "aws_security_group" "names" {
  for_each    = toset(local.names)

  name        = each.value # key and value is the same for sets
  description = each.value
}
output "security_groups" {
  value = aws_security_group.names
}
Expand to see terraform apply results.
$ terraform apply
Outputs:

security_groups = {
  "demo-example-1" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-05cec80cee397e0ef"
    "description" = "demo-example-1"
    "egress" = []
    "id" = "sg-05cec80cee397e0ef"
    "ingress" = []
    "name" = "demo-example-1"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "tags" = {}
    "vpc_id" = "vpc-11111111"
  }
  "demo-example-2" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0b6bd59983e8b3389"
    "description" = "demo-example-2"
    "egress" = []
    "id" = "sg-0b6bd59983e8b3389"
    "ingress" = []
    "name" = "demo-example-2"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "tags" = {}
    "vpc_id" = "vpc-11111111"
  }
}

Dynamic Nested Block

Now, let’s “naively” add a dynamic nested block configuration using what we learned from: Terraform Intro 5: Loops with Dynamic Block.

locals {
  names = ["demo-example-1", "demo-example-2"]
}
locals {
  ports = [80, 81]
}
resource "aws_security_group" "names" {
  for_each    = toset(local.names)

  name        = each.value # key and value is the same for sets
  description = each.value

  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"]
    }
  }
}
output "security_groups" {
  value = aws_security_group.names
}
Expand to see terraform apply results.
$ terraform apply
Outputs:

security_groups = {
  "demo-example-1" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-05cec80cee397e0ef"
    "description" = "demo-example-1"
    "egress" = []
    "id" = "sg-05cec80cee397e0ef"
    "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-example-1"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "tags" = {}
    "vpc_id" = "vpc-11111111"
  }
  "demo-example-2" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0b6bd59983e8b3389"
    "description" = "demo-example-2"
    "egress" = []
    "id" = "sg-0b6bd59983e8b3389"
    "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-example-2"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "tags" = {}
    "vpc_id" = "vpc-11111111"
  }
}

We did is “naive” because currently, the dynamic nested block has the same ingress security rules for every security group. IE: cidr_blocks = ["0.0.0.0/0"]. That’s not very useful.

Combining the Nested Loops Properly

The key to a nested loop is having the proper data structure. Let’s combine and move the ingress rules into the primary data structure, the security groups themselves.

locals {
  groups = {
    example0 = {
      description = "sg description 0"
      rules = [{
        description = "rule description 0",
        port = 80,
        cidr_blocks = ["10.0.0.0/16"],
      },{
        description = "rule description 1",
        port = 81,
        cidr_blocks = ["10.1.0.0/16"],
      }]
    },
    example1 = {
      description = "sg description 1"
      rules = [{
        description = "rule description 0",
        port = 80,
        cidr_blocks = ["10.2.0.0/16"],
      },{
        description = "rule description 1",
        port = 81,
        cidr_blocks = ["10.3.0.0/16"],
      }]
    }
  }
}
resource "aws_security_group" "this" {
  for_each    = local.groups

  name        = each.key # top-level key is security group name
  description = each.value.description

  dynamic "ingress" {
    for_each = each.value.rules # List of Maps with rule attributes
    content {
      description = ingress.value.description
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}
output "security_groups" {
  value = aws_security_group.this
}
Expand to see terraform apply results.
$ terraform apply
Outputs:

security_groups = {
  "example0" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-05a49ba9dd159608c"
    "description" = "sg description 0"
    "egress" = []
    "id" = "sg-05a49ba9dd159608c"
    "ingress" = [
      {
        "cidr_blocks" = [
          "10.0.0.0/16",
        ]
        "description" = "rule description 0"
        "from_port" = 80
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 80
      },
      {
        "cidr_blocks" = [
          "10.1.0.0/16",
        ]
        "description" = "rule description 1"
        "from_port" = 81
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 81
      },
    ]
    "name" = "example0"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "tags" = {}
    "vpc_id" = "vpc-11111111"
  }
  "example1" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0ba2e3ff20952cc52"
    "description" = "sg description 1"
    "egress" = []
    "id" = "sg-0ba2e3ff20952cc52"
    "ingress" = [
      {
        "cidr_blocks" = [
          "10.2.0.0/16",
        ]
        "description" = "rule description 0"
        "from_port" = 80
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 80
      },
      {
        "cidr_blocks" = [
          "10.3.0.0/16",
        ]
        "description" = "rule description 1"
        "from_port" = 81
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 81
      },
    ]
    "name" = "example1"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "tags" = {}
    "vpc_id" = "vpc-11111111"
  }
}

We can now have different ingress rules for each security group. The ingress rules are no longer hardcoded. This achieves the nested loop.

Combined the Nested Loops with Flatter Data Structures

While some folks like heirarchal data structures, some prefer to “flatten” the data structure into 2 different variables. Here’s an example of that:

locals {
  groups = {
    example0 = {
      description = "sg description 0"
    },
    example1 = {
      description = "sg description 1"
    }
  }

  rules = {
    example0 = [{
      description = "rule description 0",
      port = 80,
      cidr_blocks = ["10.0.0.0/16"],
    },{
      description = "rule description 1",
      port = 81,
      cidr_blocks = ["10.1.0.0/16"],
    }]
    example1 = [{
      description = "rule description 0",
      port = 80,
      cidr_blocks = ["10.2.0.0/16"],
    },{
      description = "rule description 1",
      port = 81,
      cidr_blocks = ["10.3.0.0/16"],
    }]
  }
}

resource "aws_security_group" "this" {
  for_each    = local.groups

  name        = each.key # top-level key is security group name
  description = each.value.description

  dynamic "ingress" {
    for_each = local.rules[each.key] # List of Maps with rule attributes
    content {
      description = ingress.value.description
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}
output "security_groups" {
  value = aws_security_group.this
}
Expand to see terraform apply results.
$ terraform apply
Outputs:

security_groups = {
  "example0" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0cbf2a34134e612e9"
    "description" = "sg description 0"
    "egress" = []
    "id" = "sg-0cbf2a34134e612e9"
    "ingress" = [
      {
        "cidr_blocks" = [
          "10.0.0.0/16",
        ]
        "description" = "rule description 0"
        "from_port" = 80
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 80
      },
      {
        "cidr_blocks" = [
          "10.1.0.0/16",
        ]
        "description" = "rule description 1"
        "from_port" = 81
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 81
      },
    ]
    "name" = "example0"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "vpc_id" = "vpc-11111111"
  }
  "example1" = {
    "arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-092de6661edd12b9b"
    "description" = "sg description 1"
    "egress" = []
    "id" = "sg-092de6661edd12b9b"
    "ingress" = [
      {
        "cidr_blocks" = [
          "10.2.0.0/16",
        ]
        "description" = "rule description 0"
        "from_port" = 80
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 80
      },
      {
        "cidr_blocks" = [
          "10.3.0.0/16",
        ]
        "description" = "rule description 1"
        "from_port" = 81
        "ipv6_cidr_blocks" = []
        "prefix_list_ids" = []
        "protocol" = "tcp"
        "security_groups" = []
        "self" = false
        "to_port" = 81
      },
    ]
    "name" = "example1"
    "name_prefix" = ""
    "owner_id" = "111111111111"
    "revoke_rules_on_delete" = false
    "vpc_id" = "vpc-11111111"
  }
}

We’ve achieved the same result: a nested loop that can create as many security groups as we want with different ingress rules for each security group. This time with two different variables and flatter data structures.

Consideration: Updates with Removal

There’s a subtle but important consideration with the current code. It happens when the code gets updated, particularly when previously added elements are removed.

For example, let’s say we first use the code above and run a terraform apply. That creates security groups with rules. Then we delete the rules from the code. Running terraform apply again will not remove the rules.

This is because when there’s an empty List, the for_each loop never iterates. If you wish for the security group rules to maintain its current state set outside of Terraform, you may want this behavior. However, this is probably unexpected and undesirable behavior.

If you want to have Terraform remove all the security group rules, then ingress needs to be assigned directly with a List. We’ll cover how to do that shortly.

Configuration Blocks Are Syntactical Sugar

First, it is useful to understand that the configuration block syntax ability is syntactical sugar. We covered this in the previous post, and it’s worth highlighting again. 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
  }]
}

Here, ingress is directly assigned with the equal sign. There are also extra attributes that must be explicitly be set like ipv6_cidr_blocks and prefix_list_ids. When using the syntactical sugar version, defaults are set for us. When assigned directly, we must set additional attributes because we’re setting the raw values.

Understanding that configuration blocks can be assigned directly will be useful for resetting and removing elements.

Direct Assignment Approach

For attributes designed for the block configuration syntax, we can also directly assign the attribute with an List of Maps instead. Here’s an example where we directly assign ingress:

locals {
  groups = {
    example0 = {
      description = "sg description 0"
    },
    example1 = {
      description = "sg description 1"
    }
  }

  rules = {
    example0 = [{
      description      = "rule description 0"
      to_port          = 80
      from_port        = 80
      protocol         = "tcp"
      cidr_blocks      = ["10.0.0.0/16"]
      ipv6_cidr_blocks = null
      prefix_list_ids  = null
      security_groups  = null
      self             = null
    },{
      description      = "rule description 1"
      to_port          = 80
      from_port        = 80
      protocol         = "tcp"
      cidr_blocks      = ["10.1.0.0/16"]
      ipv6_cidr_blocks = null
      prefix_list_ids  = null
      security_groups  = null
      self             = null
    }]
    example1 = [] # empty List removes previous rules
  }
}
resource "aws_security_group" "this" {
  for_each    = local.groups

  name        = each.key # top-level key is security group name
  description = each.value.description

  # direct assignment of List of Maps
  ingress = lookup(local.rules, each.key, null) == null ? [] : local.rules[each.key]
}
Expand to see terraform plan results.
$ terraform plan
Terraform will perform the following actions:

  # aws_security_group.this["example1"] will be updated in-place
  ~ resource "aws_security_group" "this" {
        arn                    = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-020565b90055f8df1"
        description            = "sg description 1"
        egress                 = []
        id                     = "sg-020565b90055f8df1"
      ~ ingress                = [
          - {
              - cidr_blocks      = [
                  - "10.2.0.0/16",
                ]
              - description      = "rule description 0"
              - from_port        = 80
              - ipv6_cidr_blocks = []
              - prefix_list_ids  = []
              - protocol         = "tcp"
              - security_groups  = []
              - self             = false
              - to_port          = 80
            },
          - {
              - cidr_blocks      = [
                  - "10.3.0.0/16",
                ]
              - description      = "rule description 1"
              - from_port        = 80
              - ipv6_cidr_blocks = []
              - prefix_list_ids  = []
              - protocol         = "tcp"
              - security_groups  = []
              - self             = false
              - to_port          = 80
            },
        ]
        name                   = "example1"
        owner_id               = "111111111111"
        revoke_rules_on_delete = false
        tags                   = {}
        vpc_id                 = "vpc-11111111"
    }

Plan: 0 to add, 1 to change, 0 to destroy.

There are advantages to this approach:

  • When rules are removed, Terraform will remove the rules also.
  • We’ve removed the second inner loop. It’s simpler in the sense that there’s no nested loop anymore. There’s only one outer loop at the resource level.

There are also some cons, though:

  • A bothersome thing about this code is we must to set default values for attributes like ipv6_cidr_blocks and prefix_list_ids. It introduces duplication.
  • We did not have to set these extra attributes when we were using the configuration block syntax.
  • In the next few posts, we’ll cover the for .. in loop, which can be used to remove this duplication.

Cleanup

Note, remember to clean up the resources:

terraform destroy

Summary

This post covered how to perform nested loops with an outer resource-level for_each loop and an inner dynamic nested block loop. We showed examples of hierarchical and flat data structures. We pointed out that the for_each technique does not remove existing elements. We then covered the direct assignment approach, which will remove existing elements. Terraform’s declarative loops can be tricky for those used to the procedural language loops, so hopefully, this post is helpful. In the next few posts, we’ll cover the for ... in loop.

The source code for the examples is available at: terraform-hcl-tutorials/6-nested-loops

Want It to be Easier to Work with Terraform?

Check out Terraspace: The Terraform Framework.

The Terraform HCL Language Intro Tutorials