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
}
}dynamic: The keyword instructing Terraform to initiate a nested loop."block_name": The actual name of the nested block expected by the provider (e.g.,"ingress","egress","route").for_each: The collection (list, set, or map) you want to iterate over.content: The actual configuration to inject on every iteration.block_name.value: The iterator object (similar toeach.valuein 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-dynamicCreate 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 planExpected 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#
Blocks of type "ingress" are not expected here.- Root Cause: You placed a
dynamic "ingress"block inside a resource that does not support nestedingressconfigurations (e.g., inside anaws_instanceinstead of anaws_security_group). - Solution: Check the Terraform Registry documentation for the resource you are using to see exactly which nested blocks it supports.
- Root Cause: You placed a
Reference to undeclared resource / Undefined iterator- Root Cause: Inside your
content {}block, you usedeach.valueinstead of the dynamic block name (e.g.,ingress.value). - Solution: Standard
for_eachloops on resources useeach.key. Dynamic blocks use the name of the block itself (e.g.,ingress.key,route.value).
- Root Cause: Inside your
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.

