What You’ll Learn
Terraform dynamic blocks let you generate repeated nested configuration from a collection such as a map or list. In this lesson, you will use them to create reusable security-group ingress and egress rules without manually repeating each nested block.
- Understand how a
dynamicblock expands into multiple nested blocks. - Use maps and lists of objects as reusable network-rule configuration.
- Reference the current item with
ingress.valueor a custom iterator. - Recognize when a dynamic block is appropriate and when
for_eachon a resource is better.
The Concept
A Terraform dynamic block generates nested blocks inside a resource, data source, or another supported block. It is useful when the number of nested blocks depends on a variable or collection.
For example, an AWS security group can contain several nested ingress blocks. Without a dynamic block, you would write one block for every allowed traffic source. With a dynamic block, you can store the rules in a map or list and let Terraform generate the nested blocks.
A dynamic block has three important parts:
- Label: The nested block type to generate, such as
ingress. for_each: The collection Terraform iterates over.content: The body of each generated nested block.
Inside content, Terraform creates an iterator named after the dynamic block label by default. For a dynamic ingress block, ingress.key is the current map key and ingress.value is the current object.
Dynamic blocks are especially useful for configuration that changes by environment. For instance, development might allow access from a wider set of office networks, while production might allow only trusted application and monitoring networks.
Basic Example
The following configuration creates an AWS security group whose ingress and egress rules come from variables. The map keys identify each rule, while the object values contain the actual AWS rule attributes.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "us-east-1"
}
data "aws_vpc" "default" {
default = true
}
variable "ingress_rules" {
type = map(object({
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = {
https_from_office = {
description = "HTTPS from the corporate office"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["203.0.113.0/24"]
}
ssh_from_admin = {
description = "SSH from the administration network"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["198.51.100.0/24"]
}
}
}
variable "egress_rules" {
type = map(object({
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = {
https_to_internet = {
description = "HTTPS outbound access"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
dns_to_resolvers = {
description = "DNS queries to network resolvers"
from_port = 53
to_port = 53
protocol = "udp"
cidr_blocks = ["10.0.0.2/32"]
}
}
}
resource "aws_security_group" "web" {
name = "web-dynamic-rules"
description = "Web security group generated from reusable rule maps"
vpc_id = data.aws_vpc.default.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
dynamic "egress" {
for_each = var.egress_rules
content {
description = egress.value.description
from_port = egress.value.from_port
to_port = egress.value.to_port
protocol = egress.value.protocol
cidr_blocks = egress.value.cidr_blocks
}
}
}
output "security_group_id" {
value = aws_security_group.web.id
}
output "ingress_rule_count" {
value = length(var.ingress_rules)
}
output "egress_rule_count" {
value = length(var.egress_rules)
}
Expected Output
With the default values, Terraform generates one security group containing two ingress rules and two egress rules. The exact security-group ID is assigned by AWS, but the rule-count outputs are deterministic.
ingress_rule_count = 2
egress_rule_count = 2
How the Code Works
The variables use map(object(...)). Each map entry has a stable key, such as https_from_office, and an object containing the rule properties. Keeping the rules in a typed variable makes the expected structure clear and helps Terraform detect invalid input during planning.
This block iterates over the ingress map:
for_each = var.ingress_rulescreates one generated block for every map entry.ingress.valuerefers to the current rule object.- Expressions such as
ingress.value.from_portcopy values into the generated AWSingressblock.
The egress block follows the same pattern with var.egress_rules. Terraform evaluates both collections and produces the nested configuration before the provider creates the security group.
The map keys do not appear directly in the AWS rule arguments in this example. They are still useful because they give each rule a readable identity and provide stable keys if you later validate, merge, or transform the collection.
Notice that the dynamic blocks generate nested blocks inside one aws_security_group resource. They do not create separate Terraform resources. If each rule needs its own lifecycle, importing behavior, or independent attachment, separate resources such as aws_vpc_security_group_ingress_rule with for_each may be a better design.
Another Example
A list of objects can also drive a dynamic block. This version uses a custom iterator named rule, which can make larger configurations easier to read. The configuration represents an application-tier security group with separate rules for a load balancer, a monitoring network, and an administration network.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "us-east-1"
}
data "aws_vpc" "default" {
default = true
}
variable "application_ingress_rules" {
type = list(object({
name = string
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{
name = "load_balancer_health_checks"
description = "Application traffic from the load balancer subnets"
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["10.20.0.0/20"]
},
{
name = "monitoring_agents"
description = "Monitoring traffic from the observability network"
from_port = 9100
to_port = 9100
protocol = "tcp"
cidr_blocks = ["10.30.0.0/24"]
},
{
name = "emergency_administration"
description = "Emergency administration access"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["198.51.100.0/24"]
}
]
}
variable "application_egress_rules" {
type = list(object({
name = string
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{
name = "database_connections"
description = "Connections to private database networks"
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.40.0.0/16"]
},
{
name = "https_updates"
description = "HTTPS access for application updates"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
]
}
resource "aws_security_group" "application" {
name = "application-dynamic-rules"
description = "Application-tier network access"
vpc_id = data.aws_vpc.default.id
dynamic "ingress" {
for_each = var.application_ingress_rules
iterator = rule
content {
description = rule.value.description
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
}
dynamic "egress" {
for_each = var.application_egress_rules
iterator = rule
content {
description = rule.value.description
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
}
}
output "application_ingress_rule_names" {
value = [for rule in var.application_ingress_rules : rule.name]
}
Unlike the first example, this configuration uses lists because the input is naturally ordered and the rule name is stored as a property. The custom iterator = rule declaration prevents the generated block label from also serving as the iterator name.
Common Mistakes
- Using a dynamic block for a top-level resource: Dynamic blocks generate nested blocks only. Use a resource-level
for_eachwhen you need multiple independent resources. - Referencing the collection instead of the current item: Inside
content, useingress.value.from_portorrule.value.from_port, notvar.ingress_rules.from_port. - Providing the wrong collection shape: A variable declared as
map(object(...))must receive a map whose values contain all required object attributes. - Using unstable list ordering when identity matters: Lists are useful for ordered input, but maps usually communicate rule identity more clearly. If rules need stable addressing, a map or a transformed map is often easier to maintain.
- Allowing overly broad network access: A dynamic block makes it easy to generate many rules, but it does not make those rules safe. Review CIDR ranges, ports, and protocols before applying changes.
Try It Yourself
Create a variable named database_ingress_rules containing at least two rule objects. Use a dynamic ingress block to allow PostgreSQL traffic from two private network ranges. Add an output that reports the number of configured database rules.
Challenge
Create a complete AWS security-group configuration named api with these requirements:
- Store ingress rules in a map of typed objects.
- Allow HTTPS on port 443 from an office network and a monitoring network.
- Allow SSH on port 22 from an administration network.
- Store egress rules in a separate map.
- Allow HTTPS egress to the internet and DNS/UDP egress to a private resolver.
- Generate all ingress and egress nested blocks with dynamic blocks.
- Output the number of generated ingress rules and the security-group ID.
Solution
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "us-east-1"
}
data "aws_vpc" "default" {
default = true
}
variable "api_ingress_rules" {
type = map(object({
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = {
https_from_office = {
description = "HTTPS from the office network"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["203.0.113.0/24"]
}
https_from_monitoring = {
description = "HTTPS from the monitoring network"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["10.30.0.0/24"]
}
ssh_from_administration = {
description = "SSH from the administration network"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["198.51.100.0/24"]
}
}
}
variable "api_egress_rules" {
type = map(object({
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = {
https_to_internet = {
description = "HTTPS access to external services"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
dns_to_private_resolver = {
description = "DNS requests to the private resolver"
from_port = 53
to_port = 53
protocol = "udp"
cidr_blocks = ["10.0.0.2/32"]
}
}
}
resource "aws_security_group" "api" {
name = "api-dynamic-rules"
description = "API security group generated from rule maps"
vpc_id = data.aws_vpc.default.id
dynamic "ingress" {
for_each = var.api_ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
dynamic "egress" {
for_each = var.api_egress_rules
content {
description = egress.value.description
from_port = egress.value.from_port
to_port = egress.value.to_port
protocol = egress.value.protocol
cidr_blocks = egress.value.cidr_blocks
}
}
}
output "api_security_group_id" {
value = aws_security_group.api.id
}
output "api_ingress_rule_count" {
value = length(var.api_ingress_rules)
}
The solution uses three map entries for ingress and two for egress, so Terraform generates five nested network-rule blocks inside the single security group. Adding another rule requires changing the input map rather than duplicating resource configuration.
Key Takeaways
- Terraform dynamic blocks generate repeated nested blocks from a collection.
- The default iterator is named after the generated block, such as
ingress.value. - Use
iteratorwhen a custom name improves readability. - Maps provide useful stable rule identities; lists are useful when ordered objects are more natural.
- Use resource-level
for_eachinstead when each network rule should be an independent resource.



