Terraform Dynamic Blocks: Generate Reusable Security Group Rules

Reusable configuration data generating repeated inbound and outbound network security rules

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 dynamic block expands into multiple nested blocks.
  • Use maps and lists of objects as reusable network-rule configuration.
  • Reference the current item with ingress.value or a custom iterator.
  • Recognize when a dynamic block is appropriate and when for_each on 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

A top-to-bottom process showing typed ingress and egress collections flowing into Terraform dynamic blocks. Each dynamic block iterates over the collection, uses the current item through the default or custom iterator, and expands content into repeated nested rules inside one security group. A decision distinguishes this approach from resource-level for_each when rules need independent resources.
Terraform dynamic blocks turn typed rule collections into repeated nested ingress and egress blocks; use resource-level for_each when each rule needs its own lifecycle.

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_rules creates one generated block for every map entry.
  • ingress.value refers to the current rule object.
  • Expressions such as ingress.value.from_port copy values into the generated AWS ingress block.

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_each when you need multiple independent resources.
  • Referencing the collection instead of the current item: Inside content, use ingress.value.from_port or rule.value.from_port, not var.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 iterator when a custom name improves readability.
  • Maps provide useful stable rule identities; lists are useful when ordered objects are more natural.
  • Use resource-level for_each instead when each network rule should be an independent resource.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top