Skip to main content

Terraform Ep 8: Generating Nested Configurations with Dynamic Blocks

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
terraform - This article is part of a series.
Part 8: This Article
In the previous episode, we learned how to use for_each to create multiple distinct resources (like 3 different Subnets). But what if you only want to create ONE Security Group, but it needs 20 different ingress rules? Copy-pasting the ingress {} block 20 times inside the resource is tedious and rigid. Enter the Dynamic Block.

1. The Problem: Nested Blocks
#

Many Terraform resources require nested configuration blocks. The most classic example is the aws_security_group.

A standard, statically-defined Security Group looks like this:

resource "aws_security_group" "web_sg" {
  name   = "web-tier-sg"
  vpc_id = "vpc-12345"

  # We have to write an entire block for Port 80
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # We have to copy-paste the block for Port 443
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

If your security requirements dictate 15 different open ports, this file becomes hundreds of lines long. Worse, if you want to turn this into a reusable module (Episode 9) where the user can pass in a variable list of ports, static blocks simply will not work.


2. The Solution: dynamic Blocks
#

Terraform provides the dynamic block construct to programmatically generate these nested configurations by looping over a variable (like a List or a Map).

Anatomy of a Dynamic Block
#

dynamic "block_name" {
  for_each = var.my_list
  
  content {
    parameter_1 = block_name.value
  }
}
  1. dynamic: The keyword instructing Terraform to initiate a nested loop.
  2. "block_name": The actual name of the nested block expected by the provider (e.g., "ingress", "egress", "route").
  3. for_each: The collection (list, set, or map) you want to iterate over.
  4. content: The actual configuration to inject on every iteration.
  5. block_name.value: The iterator object (similar to each.value in a standard loop). It takes the name of the block!

3. Practice: Building a Dynamic Security Group
#

Let’s convert the static Security Group from Section 1 into a highly dynamic, variable-driven architecture.

Create a new directory named terraform-dynamic:

mkdir terraform-dynamic
cd terraform-dynamic

Create a main.tf file and paste the following configuration:

# 1. Define a list of ports we want to open
variable "web_ports" {
  description = "List of ingress ports to open"
  type        = list(number)
  default     = [80, 443, 8080, 8443]
}

provider "aws" {
  region = "us-east-1"
}

# 2. Construct the Security Group dynamically
resource "aws_security_group" "dynamic_sg" {
  name        = "dynamic-web-sg"
  description = "Dynamically generated ingress rules"
  vpc_id      = "vpc-12345678" # Assumes a default VPC exists for demo purposes

  # Here is the magic!
  dynamic "ingress" {
    # Loop over the 4 ports in the variable
    for_each = var.web_ports

    # The 'content' block will be stamped out 4 times
    content {
      # We access the current iteration using 'ingress.value'
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }

  # We can still add static blocks alongside dynamic ones
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Execution and Observation
#

Run a plan to see how Terraform expands the dynamic block in memory:

terraform init
terraform plan

Expected Terminal Output:

Terraform will perform the following actions:

  # aws_security_group.dynamic_sg will be created
  + resource "aws_security_group" "dynamic_sg" {
      + arn                    = (known after apply)
      + description            = "Dynamically generated ingress rules"
      + id                     = (known after apply)
      + name                   = "dynamic-web-sg"
      
      + egress {
          + cidr_blocks = [
              + "0.0.0.0/0",
            ]
          + from_port   = 0
          + protocol    = "-1"
          + to_port     = 0
        }

      + ingress {
          + cidr_blocks = [
              + "0.0.0.0/0",
            ]
          + from_port   = 80
          + protocol    = "tcp"
          + to_port     = 80
        }
      + ingress {
          + cidr_blocks = [
              + "0.0.0.0/0",
            ]
          + from_port   = 443
          + protocol    = "tcp"
          + to_port     = 443
        }
      # ... (8080 and 8443 blocks also generated)
    }

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

Notice how the plan output shows 4 distinct ingress blocks! You wrote only one content block, but Terraform intelligently expanded it based on the input variable.


4. Advanced Dynamic Blocks with Complex Objects
#

In the real world, you rarely open every port to the entire internet (0.0.0.0/0). Usually, different ports require different CIDR restrictions.

We can achieve this by changing our variable from a simple list(number) to a Map of Objects, just like we did in the previous episode.

variable "complex_rules" {
  type = map(object({
    port = number
    cidr = string
  }))
  default = {
    "public_web"    = { port = 443, cidr = "0.0.0.0/0" }
    "internal_api"  = { port = 8080, cidr = "10.0.0.0/16" }
    "admin_ssh"     = { port = 22, cidr = "192.168.1.50/32" }
  }
}

resource "aws_security_group" "complex_sg" {
  name   = "complex-sg"
  vpc_id = "vpc-12345"

  dynamic "ingress" {
    for_each = var.complex_rules
    
    content {
      # Because we passed a Map, we can access the object properties
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [ingress.value.cidr]
      description = "Rule for ${ingress.key}" # 'public_web', 'internal_api'
    }
  }
}

This is the absolute pinnacle of dynamic nested configurations. A single concise block of code can now generate infinitely complex security topologies purely based on the variables provided.


Troubleshooting & Common Errors
#

  1. Blocks of type "ingress" are not expected here.

    • Root Cause: You placed a dynamic "ingress" block inside a resource that does not support nested ingress configurations (e.g., inside an aws_instance instead of an aws_security_group).
    • Solution: Check the Terraform Registry documentation for the resource you are using to see exactly which nested blocks it supports.
  2. Reference to undeclared resource / Undefined iterator

    • Root Cause: Inside your content {} block, you used each.value instead of the dynamic block name (e.g., ingress.value).
    • Solution: Standard for_each loops on resources use each.key. Dynamic blocks use the name of the block itself (e.g., ingress.key, route.value).

Conclusion & Next Steps
#

You now possess the final piece of the HCL programmatic puzzle. You can iterate over entire resources using count and for_each, and iterate inside resources using dynamic blocks.

At this point, your configuration files are incredibly flexible. But they are still confined to a single directory. If another team wants to deploy your beautifully dynamic Security Group, they would have to copy-paste your main.tf file.

In Episode 9: Architecting Reusable, Production-Grade Modules, we will package this logic into an independent, version-controlled Module that the entire enterprise can consume.

terraform - This article is part of a series.
Part 8: This Article