Mastering Terraform Dynamic Block

In today’s blog post, we will learn terraform dynamic block. The dynamic block was Introduced to solve the challenge of dynamically generating a nested configuration blocks, the dynamic block is a powerful Terraform construct that allows you to build repeatable blocks based on complex data structures.

What is the Terraform dynamic Block?

The dynamic block allows you to construct repeatable nested blocks within a resource, data source, provider, or provisioner configuration. It’s essentially a for_each loop specifically designed for generating these internal blocks, whose number and content are not fixed but are derived from a given collection (list, map, or set).

Think of it as a programmatic way to generate configuration blocks, similar to how you use for_each on a resource to create multiple instances of that resource.

Why Use dynamic Blocks? (The DRY Principle)

  • Reduce Repetition: Avoid copying and pasting identical or very similar nested blocks multiple times.
  • Increase Flexibility: Create a variable number of blocks based on input variables or computed data.
  • Improve Readability: Incorporate complex logic for generating blocks into a clean, iterable structure.
  • Dynamic Configurations: Drive parts of your resource configuration directly from data.

Syntax of the dynamic Block

A dynamic block has a distinct structure:

dynamic "<BLOCK_LABEL>" {
  for_each = <COLLECTION> # A list, map, or set to iterate over

  # Optional: Define the iteration variable names
  iterator = <NAME_FOR_EACH_ITEM> # Default is "value"

  content {
    # Arguments for the <BLOCK_LABEL> go here.
    # You can reference <NAME_FOR_EACH_ITEM>.<ATTRIBUTE> or <NAME_FOR_EACH_ITEM>.key and <NAME_FOR_EACH_ITEM>.value
  }
}
  • <BLOCK_LABEL>: This is the name of the nested block that you want to generate dynamically. For example, if you’re defining security group rules, this would be ingress or egress.
  • for_each: (Required) This is the collection (list, map, or set) that Terraform will iterate over. For each element in this collection, a new instance of the <BLOCK_LABEL> will be generated.
  • iterator: (Optional) By default, Terraform assigns value to the current element being iterated. If for_each is over a map, key is also available. You can rename this variable for clarity (e.g., rule, nic, item).
  • content: (Required) This block defines the arguments that belong inside each generated instance of the <BLOCK_LABEL>. You will typically reference the iterator variable (e.g., rule.port, nic.ip_address) to set values dynamically.

Common Use Cases and Examples

Let us explore the power of the dynamic block with practical scenarios.

Terraform dynamic block with for_each and List of Objects/Maps

This is the most common and powerful use case, combining dynamic with for_each to iterate over a list of complex objects (which are essentially maps of attributes).

Scenario: Create a security group that allows specific ingress rules defined by an input variable.

# variables.tf
variable "ingress_rules" {
  description = "A list of ingress rule objects for the security group."
  type = list(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
    description = string
    # Optional: Add a 'rule_id' for explicit identification if needed
    rule_id     = string 
  }))
  default = [
    {
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow HTTP"
      rule_id     = "http_any"
    },
    {
      from_port   = 443
      to_port     = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow HTTPS"
      rule_id     = "https_any"
    },
    {
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = ["192.168.1.0/24"]
      description = "Allow SSH from internal network"
      rule_id     = "ssh_internal"
    }
  ]
}
# main.tf
resource "aws_security_group" "web_sg" {
  name        = "dynamic-web-sg"
  description = "Security group with dynamic ingress rules"
  vpc_id      = "vpc-0abcdef1234567890" # Replace with your VPC ID

  dynamic "ingress" { # The BLOCK_LABEL is "ingress"
    # Use a 'for' expression to convert the list of objects into a map
    # where the 'rule_id' becomes the map key. This is robust for for_each.
    for_each = { for rule in var.ingress_rules : rule.rule_id => rule }
    iterator = rule_data # Name the current element "rule_data" for clarity

    content { # Define the arguments for each "ingress" block
      from_port   = rule_data.value.from_port
      to_port     = rule_data.value.to_port
      protocol    = rule_data.value.protocol
      cidr_blocks = rule_data.value.cidr_blocks
      description = rule_data.value.description
    }
  }

  egress { # Can still mix with static blocks
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Explanation:

  • The for_each argument is provided a map created from the var.ingress_rules list, using rule.rule_id as the key. This makes each generated ingress block uniquely identified by its rule_id, which is crucial for Terraform’s state management with for_each.
  • The iterator = rule_data assigns the current key-value pair of the for_each map to rule_data. Inside content, rule_data.value refers to the original rule object (e.g., {from_port=80, ...}).

Terraform dynamic block with Nested Loops

Generate deeply nested configurations where a list of items each has its own list of sub-items.

Scenario: Create an Azure Network Security Group (NSG) with multiple security rules, and each rule can have multiple source/destination port ranges.

# variables.tf
variable "nsg_rules_config" {
  description = "Configuration for NSG rules, including multiple port ranges."
  type = list(object({
    name                     = string
    priority                 = number
    direction                = string
    access                   = string
    protocol                 = string
    source_address_prefix    = string
    destination_address_prefix = string
    source_port_ranges       = list(string)
    destination_port_ranges  = list(string)
  }))
  default = [
    {
      name                      = "Allow-SSH"
      priority                  = 100
      direction                 = "Inbound"
      access                    = "Allow"
      protocol                  = "Tcp"
      source_address_prefix     = "*"
      destination_address_prefix = "*"
      source_port_ranges        = ["*"]
      destination_port_ranges   = ["22"]
    },
    {
      name                      = "Allow-Web"
      priority                  = 110
      direction                 = "Inbound"
      access                    = "Allow"
      protocol                  = "Tcp"
      source_address_prefix     = "Internet"
      destination_address_prefix = "*"
      source_port_ranges        = ["*"]
      destination_port_ranges   = ["80", "443"] # Multiple ports for this rule
    }
  ]
}
# main.tf
resource "azurerm_network_security_group" "main" {
  name                = "my-dynamic-nsg"
  location            = "eastus" # Replace with your location
  resource_group_name = "my-resource-group" # Replace with your RG name

  dynamic "security_rule" { # Outer dynamic loop for each security rule
    for_each = var.nsg_rules_config
    iterator = rule_data # Current rule object

    content {
      name                       = rule_data.value.name
      priority                   = rule_data.value.priority
      direction                  = rule_data.value.direction
      access                     = rule_data.value.access
      protocol                   = rule_data.value.protocol
      source_address_prefix      = rule_data.value.source_address_prefix
      destination_address_prefix = rule_data.value.destination_address_prefix

      # Nested dynamic block for source_port_ranges
      dynamic "source_port_range" {
        for_each = rule_data.value.source_port_ranges # Iterate over source port ranges of *this* rule
        iterator = src_port

        content {
          source_port_range = src_port.value
        }
      }

      # Nested dynamic block for destination_port_ranges
      dynamic "destination_port_range" {
        for_each = rule_data.value.destination_port_ranges # Iterate over dest port ranges of *this* rule
        iterator = dest_port

        content {
          destination_port_range = dest_port.value
        }
      }
    }
  }
}

Explanation:

  • The outer dynamic "security_rule" iterates over the list of rule configurations.
  • Inside its content block, two more dynamic blocks are defined: source_port_range and destination_port_range.
  • These inner dynamic blocks iterate over the source_port_ranges and destination_port_ranges lists of the current outer rule. This creates a highly flexible way to define NSG rules with multiple port ranges per rule.

Terraform dynamic block with Conditional Logic

Conditionally include or exclude attributes or entire blocks based on specific conditions.

Scenario: Create a database user with an optional password, and conditionally apply a description based on whether it is for production.

# variables.tf
variable "db_users" {
  description = "A list of database user configurations."
  type = list(object({
    name        = string
    password    = string # Can be empty for non-prod
    is_prod_user = bool
  }))
  default = [
    {
      name        = "app_user_dev"
      password    = ""
      is_prod_user = false
    },
    {
      name        = "app_user_prod"
      password    = "SecurePassword!"
      is_prod_user = true
    }
  ]
}
# main.tf (Conceptual database user resource)
resource "null_resource" "db_users" { # Using null_resource for demonstration
  for_each = { for user in var.db_users : user.name => user }

  triggers = {
    user_name = each.value.name
  }

  dynamic "auth_details" { # Assume 'auth_details' is a nested block for credentials
    for_each = each.value.password != "" ? [1] : [] # If password is not empty, generate this block
    content {
      password = each.value.password # This attribute is only present if the block is generated
    }
  }

  dynamic "description_block" { # Assume 'description_block' is an optional nested block
    for_each = each.value.is_prod_user ? [1] : [] # Only generate if it's a prod user
    content {
      text = "This user is for production environment use."
      # You could add other attributes specific to production users here
    }
  }

  provisioner "local-exec" {
    command = "echo 'Configuring user ${each.value.name}. Password set: ${each.value.password != ""}. Prod user: ${each.value.is_prod_user}'"
  }
}

Explanation:

  • Conditional Block Generation:
    • dynamic "auth_details": The for_each is each.value.password != "" ? [1] : []. This creates a list containing 1 if the password is not empty, and an empty list if it is. The dynamic block will only be generated when for_each has at least one element.
    • dynamic "description_block": Similarly, each.value.is_prod_user ? [1] : [] ensures this block is only created for production users.
  • This allows you to omit entire nested blocks from the generated configuration based on runtime conditions.

Terraform dynamic block with count

While for_each is generally preferred for dynamic blocks, count can be used when you need to generate a fixed or numerically indexed set of blocks.

Scenario: Create a set of virtual network interfaces, where the number is driven by count, and each interface has a specific configuration based on its index.

# variables.tf
variable "num_nics" {
  description = "Number of network interfaces to create for a VM."
  type        = number
  default     = 2
}

# main.tf (Conceptual VM resource with dynamic NICs)
resource "null_resource" "my_vm_with_nics" {
  count = 1 # Just one VM for this example

  triggers = {
    nic_count = var.num_nics
  }

  dynamic "network_interface" { # Assume 'network_interface' is a nested block
    for_each = range(var.num_nics) # Iterate from 0 to num_nics-1
    iterator = nic_index # The current index (0, 1, 2...)

    content {
      name       = "nic-${nic_index.value}"
      ip_address = "10.0.0.${10 + nic_index.value}" # IP based on index
      is_primary = nic_index.value == 0
      # ... other NIC specific properties
    }
  }

  provisioner "local-exec" {
    command = "echo 'VM with ${var.num_nics} interfaces configured.'"
  }
}

Explanation:

  • for_each = range(var.num_nics) generates a list of numbers (e.g., [0, 1] if var.num_nics is 2).
  • The nic_index.value then provides the current numerical index for configuring each dynamic network interface block.

Terraform dynamic block in a Module

Dynamically generate configuration blocks within a module, allowing the module’s behavior to be customized by its inputs.

Scenario: Create a reusable module to define EC2 security groups that can accept a variable number of ingress rules.

modules/security_group/variables.tf:

# modules/security_group/variables.tf
variable "name" {
  description = "Name of the security group."
  type        = string
}

variable "vpc_id" {
  description = "The VPC ID to associate the security group with."
  type        = string
}

variable "ingress_rules" {
  description = "A list of ingress rule objects for the security group."
  type = list(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
    description = string
    rule_id     = string # Important for for_each keys
  }))
  default = [] # Default to empty list if no rules are passed
}

modules/security_group/main.tf:

# modules/security_group/main.tf
resource "aws_security_group" "this" {
  name        = var.name
  description = "Managed by module: ${var.name}"
  vpc_id      = var.vpc_id

  dynamic "ingress" {
    # Use a 'for' expression to convert the list of objects into a map
    # where the 'rule_id' becomes the map key. This is robust for for_each.
    for_each = { for rule in var.ingress_rules : rule.rule_id => rule }
    iterator = rule_data

    content {
      from_port   = rule_data.value.from_port
      to_port     = rule_data.value.to_port
      protocol    = rule_data.value.protocol
      cidr_blocks = rule_data.value.cidr_blocks
      description = rule_data.value.description
    }
  }

  egress { # Default egress rule for the module
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

output "security_group_id" {
  value = aws_security_group.this.id
}

root/main.tf (Consuming the Module):

# root/main.tf
module "web_app_sg" {
  source = "./modules/security_group" # Path to your module
  name   = "web-app-sg"
  vpc_id = "vpc-0abcdef1234567890" # Your VPC ID

  ingress_rules = [
    {
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow HTTP to web app"
      rule_id     = "http_in"
    },
    {
      from_port   = 443
      to_port     = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow HTTPS to web app"
      rule_id     = "https_in"
    }
  ]
}

module "db_sg" {
  source = "./modules/security_group"
  name   = "database-sg"
  vpc_id = "vpc-0abcdef1234567890"

  ingress_rules = [ # Fewer, more restrictive rules for DB
    {
      from_port   = 5432
      to_port     = 5432
      protocol    = "tcp"
      cidr_blocks = [module.web_app_sg.security_group_id] # Reference other SG
      description = "Allow PostgreSQL from web app SG"
      rule_id     = "db_from_web"
    }
  ]
}

Explanation:

  • The security_group module itself contains the dynamic "ingress" block.
  • The number and content of the ingress rules are entirely driven by the ingress_rules variable passed into the module from the root configuration.
  • This makes the module highly flexible, as it can be reused for different types of security groups requiring varying numbers of rules.

Best Practices for dynamic Blocks

  1. Use for_each Properly: The for_each argument is the core of dynamic blocks. Ensure the collection you are iterating over provides distinct elements that can serve as keys for the generated blocks if order matters for the underlying resource.
  2. Meaningful iterator Names: While value is the default, renaming your iterator (e.g., rule, nic, item) improves readability, especially in nested dynamic blocks.
  3. Mind the content Block: All arguments that belong to the dynamically generated block must go inside the content block.
  4. No Dynamic Meta-Arguments: You cannot use dynamic blocks to generate meta-arguments like count, for_each, lifecycle, or provisioner blocks. dynamic blocks are for nested configuration arguments within a resource, not for the resource itself.
  5. Readability vs. Complexity: While powerful, excessive nesting of dynamic blocks or overly complex for_each expressions can make your HCL harder to read and debug.
  6. Validate Input Data: Always validate the structure and content of the input variables passed to your dynamic blocks. Use variable validation to catch errors early.
  7. null Values for Optional Arguments: If an argument within a dynamic block is optional and might sometimes be absent from your input data, use try() or lookup() with a default of null to ensure Terraform does not throw an error when the key is missing.
# Example: conditionally include a description
content {
  from_port = rule.value.from_port
  to_port   = rule.value.to_port
  # Only include description if it exists in the input object
  description = try(rule.value.description, null)
}

Conclusion

The Terraform dynamic block is an essential feature for writing flexible, reusable, and maintainable Terraform configuration blocks. By enabling the dynamic generation of nested configuration blocks, it addresses the challenges of repetition and fixed structures, particularly when dealing with variable lists of settings like security group rules, network interfaces, or policy statements.

Mastering the dynamic block, especially usage of for_each, conditional statements, handling different data types like list or maps will enable you to create fully optimized and reusable terraform code.

Author

Debjeet Bhowmik

Experienced Cloud & DevOps Engineer with hands-on experience in AWS, GCP, Terraform, Ansible, ELK, Docker, Git, GitLab, Python, PowerShell, Shell, and theoretical knowledge on Azure, Kubernetes & Jenkins.
In my free time, I write blogs on ckdbtech.com

Leave a Comment