Terraform Lesson 23 – Dynamic Blocks | Dataplexa
Section II · Lesson 23

Dynamic Blocks

Some resource arguments are themselves repeating blocks — security group rules, EBS volume attachments, IAM policy statements, listener rules. When those blocks need to vary in number — one rule for dev, ten rules for prod — copy-pasting is not the answer. Dynamic blocks let you generate any number of nested blocks from a collection variable, keeping the configuration clean regardless of how many items the collection holds.

This lesson covers

Dynamic block syntax → Security group rules from a variable → Nested dynamic blocks → Dynamic blocks inside modules → When to use dynamic blocks and when not to

What Dynamic Blocks Solve

Without dynamic blocks, every nested block must be written explicitly. A security group with three ingress rules needs three ingress blocks written out by hand. A production security group with twelve rules needs twelve. When the rules change, you edit HCL. When environments need different sets of rules, you duplicate the resource.

Dynamic blocks solve this by generating nested blocks from a collection at plan time — one block per item, driven entirely by data.

Without dynamic blocks With dynamic blocks
Every rule written explicitly in HCL Rules defined in a variable, generated at plan time
Adding a rule means editing the resource block Adding a rule means adding to the variable value
Different envs need duplicate resource blocks One resource block, different variable values per env
Configuration grows linearly with rule count Configuration stays constant regardless of rule count

Setting Up

mkdir terraform-lesson-23
cd terraform-lesson-23
touch versions.tf variables.tf main.tf outputs.tf .gitignore

Add this to versions.tf:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

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

Run terraform init and continue.

Dynamic Block Syntax

A dynamic block has four parts: the dynamic keyword, the block type name you are generating, the for_each argument that drives iteration, and a content block that defines what each generated block contains.

New terms:

  • dynamic "BLOCK_TYPE" — the outer declaration. BLOCK_TYPE is the name of the nested block you are generating — for example ingress, egress, statement. It must match the block type name that the resource accepts.
  • for_each inside dynamic — the collection to iterate. Accepts maps and sets — same rules as resource-level for_each. Each item in the collection produces one instance of the nested block.
  • content block — defines the body of each generated block. Uses each.key and each.value to access the current iteration's data — same as resource-level for_each.
  • iterator argument — optional. Overrides the default iterator name from each to a custom name. Required when using nested dynamic blocks to avoid name conflicts — the outer block uses one iterator name and the inner block uses another.

Here is the anatomy of a dynamic block before we use it for real:

# Dynamic block anatomy — using security group ingress as the example

resource "aws_security_group" "example" {
  name   = "example"
  vpc_id = aws_vpc.main.id

  # A dynamic block generates zero or more copies of its content block
  # based on the for_each collection
  dynamic "ingress" {          # "ingress" = the block type to generate
    for_each = var.ingress_rules  # One ingress block per item in this collection

    # Optional — rename "each" to something more descriptive
    # Useful when nesting dynamic blocks to avoid "each" name conflicts
    iterator = rule  # Now use rule.key and rule.value instead of each.key/each.value

    content {
      # Inside content, rule.value refers to the current item's value
      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
    }
  }
}

Security Group Rules from a Variable

The most common production use of dynamic blocks is building security group rules from a variable. Define the rules in variables.tf or terraform.tfvars — the resource block never changes regardless of how many rules you need.

Add this to variables.tf:

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "ingress_rules" {
  description = "Map of ingress rules for the web security group"
  type = map(object({
    description = string
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))

  # Dev gets two rules — HTTP and HTTPS only
  # Prod tfvars can override this with more rules without touching main.tf
  default = {
    http = {
      description = "Allow HTTP from anywhere"
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
    https = {
      description = "Allow HTTPS from anywhere"
      from_port   = 443
      to_port     = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

variable "egress_rules" {
  description = "Map of egress rules for the web security group"
  type = map(object({
    description = string
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))

  default = {
    all_outbound = {
      description = "Allow all outbound traffic"
      from_port   = 0
      to_port     = 0
      protocol    = "-1"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

Now add the VPC and security group to main.tf. Notice the security group resource block stays the same size whether there are two rules or twenty:

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true

  tags = { Name = "lesson23-vpc", ManagedBy = "Terraform" }
}

# Security group with fully dynamic ingress and egress rules
# Adding a new rule = adding an entry to var.ingress_rules in tfvars
# Zero changes to this resource block required
resource "aws_security_group" "web" {
  name        = "lesson23-web-sg"
  description = "Web security group with dynamic rules"
  vpc_id      = aws_vpc.main.id

  # Dynamic ingress — generates one ingress block per entry in var.ingress_rules
  dynamic "ingress" {
    for_each = var.ingress_rules   # Iterates the map — each.key = rule name
    iterator = rule                # Rename iterator to "rule" for clarity

    content {
      description = rule.value.description  # Access object fields via rule.value
      from_port   = rule.value.from_port
      to_port     = rule.value.to_port
      protocol    = rule.value.protocol
      cidr_blocks = rule.value.cidr_blocks
    }
  }

  # Dynamic egress — same pattern for outbound rules
  dynamic "egress" {
    for_each = var.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
    }
  }

  tags = { Name = "lesson23-web-sg", ManagedBy = "Terraform" }
}
terraform apply
$ terraform apply

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

  Enter a value: yes

aws_vpc.main: Creating...
aws_vpc.main: Creation complete [id=vpc-0abc123]

aws_security_group.web: Creating...
aws_security_group.web: Creation complete [id=sg-0abc123]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

# Inspect the security group — two dynamic ingress blocks were generated
$ terraform state show aws_security_group.web

resource "aws_security_group" "web" {
    id   = "sg-0abc123"
    name = "lesson23-web-sg"

    ingress = [
      {
        cidr_blocks = ["0.0.0.0/0"]
        description = "Allow HTTP from anywhere"
        from_port   = 80
        protocol    = "tcp"
        to_port     = 80
      },
      {
        cidr_blocks = ["0.0.0.0/0"]
        description = "Allow HTTPS from anywhere"
        from_port   = 443
        protocol    = "tcp"
        to_port     = 443
      },
    ]

    egress = [
      {
        cidr_blocks = ["0.0.0.0/0"]
        description = "Allow all outbound traffic"
        from_port   = 0
        protocol    = "-1"
        to_port     = 0
      },
    ]
}

What just happened?

  • Two ingress blocks were generated from the two-entry map variable. The dynamic block iterated var.ingress_rules — which has entries "http" and "https" — and produced one ingress block for each. The resource block in configuration has exactly one dynamic "ingress" block regardless of how many rules the variable contains.
  • Adding a third rule requires zero changes to main.tf. Add an entry to the ingress_rules variable — in variables.tf or a terraform.tfvars file — and the next plan shows one new ingress rule being added. The resource block is untouched. In a production environment, a team can maintain their own prod.tfvars with a larger rule set without forking the configuration.
  • The iterator = rule renaming is a readability choice. Without iterator = rule, you would use each.value.description inside the content block. With it, you use rule.value.description — more readable because it names the thing being iterated. This matters most when nesting dynamic blocks, where the default each name conflicts.

Nested Dynamic Blocks

Some resources have blocks nested inside other blocks — for example, an IAM policy with a variable number of statements, where each statement can have a variable number of conditions. Dynamic blocks can be nested — an outer dynamic block iterating statements, an inner dynamic block iterating conditions inside each statement.

New terms:

  • nested dynamic block — a dynamic block inside the content of another dynamic block. Generates nested blocks inside each outer generated block.
  • iterator name collision — when outer and inner dynamic blocks both use the default each iterator, references inside the inner block are ambiguous — which each? Resolve this by giving the outer block a custom iterator name — then both are unambiguous.
  • aws_iam_policy_document data source — the standard way to build IAM policy JSON in Terraform. Uses nested blocks for statements and conditions rather than raw JSON strings. Dynamic blocks inside a data source work identically to dynamic blocks inside resource blocks.

Add this to variables.tf:

variable "iam_statements" {
  description = "IAM policy statements — each may have its own set of conditions"
  type = map(object({
    effect    = string        # "Allow" or "Deny"
    actions   = list(string)  # List of IAM actions e.g. ["s3:GetObject","s3:PutObject"]
    resources = list(string)  # List of resource ARNs the actions apply to
    conditions = optional(list(object({
      test     = string  # Condition operator e.g. "StringEquals", "IpAddress"
      variable = string  # Condition key e.g. "aws:RequestedRegion"
      values   = list(string)  # Values the condition matches against
    })), [])  # optional() — defaults to empty list if not provided
  }))

  default = {
    s3_read = {
      effect    = "Allow"
      actions   = ["s3:GetObject", "s3:ListBucket"]
      resources = ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"]
      # No conditions on this statement — optional defaults to []
    }
    s3_write_restricted = {
      effect    = "Allow"
      actions   = ["s3:PutObject"]
      resources = ["arn:aws:s3:::my-bucket/*"]
      conditions = [
        {
          test     = "StringEquals"
          variable = "aws:RequestedRegion"
          values   = ["us-east-1"]  # Write only allowed from us-east-1
        }
      ]
    }
  }
}

Add this to main.tf:

# IAM policy document built entirely from the variable — no hardcoded statements
data "aws_iam_policy_document" "app_policy" {

  # Outer dynamic block — one statement block per entry in var.iam_statements
  dynamic "statement" {
    for_each = var.iam_statements
    iterator = stmt  # Named "stmt" to distinguish from the inner "cond" iterator

    content {
      sid       = stmt.key             # Statement ID — the map key e.g. "s3_read"
      effect    = stmt.value.effect    # "Allow" or "Deny"
      actions   = stmt.value.actions   # List of IAM actions
      resources = stmt.value.resources # List of resource ARNs

      # Inner dynamic block — one condition block per entry in the statement's conditions list
      # stmt.value.conditions is the list of conditions for this specific statement
      # Empty list [] means no condition blocks are generated for this statement
      dynamic "condition" {
        for_each = stmt.value.conditions  # Each statement can have its own conditions
        iterator = cond                   # Named "cond" — distinct from outer "stmt"

        content {
          test     = cond.value.test      # Condition operator
          variable = cond.value.variable  # Condition key
          values   = cond.value.values    # Values to match against
        }
      }
    }
  }
}

# IAM role to attach the policy to
resource "aws_iam_role" "app" {
  name = "lesson23-app-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = { Name = "lesson23-app-role", ManagedBy = "Terraform" }
}

# Inline policy on the role — uses the dynamically-built policy document
resource "aws_iam_role_policy" "app" {
  name   = "lesson23-app-policy"
  role   = aws_iam_role.app.id         # Implicit dependency on the role
  policy = data.aws_iam_policy_document.app_policy.json  # Policy from the data source
}
$ terraform apply

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

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

# Inspect the generated policy JSON
$ terraform console

> data.aws_iam_policy_document.app_policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "s3_read",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ]
      # No Condition block — conditions list was empty, inner dynamic generated nothing
    },
    {
      "Sid": "s3_write_restricted",
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": ["arn:aws:s3:::my-bucket/*"],
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": ["us-east-1"]  # Condition was generated from the inner dynamic block
        }
      }
    }
  ]
}

What just happened?

  • The outer dynamic generated two statement blocks. One for "s3_read" and one for "s3_write_restricted" — matching the two entries in var.iam_statements. The iterator stmt made it clear which level of data was being referenced.
  • The inner dynamic generated zero condition blocks for s3_read and one for s3_write_restricted. The stmt.value.conditions list is empty for s3_read — optional(list(object(...)), []) defaulted to an empty list — so the inner dynamic produced nothing. For s3_write_restricted, the list had one entry, producing one Condition block. The same inner dynamic block handles both cases.
  • Iterator naming was essential. Without iterator = stmt on the outer block and iterator = cond on the inner block, both would use each. Inside the inner content block, each.value.test would be ambiguous — which each? The inner one. But if you forgot and needed the outer value, you would get the wrong data silently. Named iterators make nested dynamic blocks unambiguous and self-documenting.

Dynamic Blocks in Practice — EBS Volumes

Another common use case: EC2 instances with a variable number of attached EBS volumes. A dev instance might have one volume. A database server might have three — root, data, and log. Dynamic blocks make both work with the same resource block.

variable "ebs_volumes" {
  description = "Additional EBS volumes to attach to the instance — beyond the root volume"
  type = list(object({
    device_name = string  # e.g. "/dev/sdb"
    volume_size = number  # Size in GB
    volume_type = string  # "gp3", "io2", etc.
    encrypted   = bool    # Encrypt the volume at rest
  }))

  # Dev: no extra volumes
  default = []

  # Prod tfvars would override with something like:
  # default = [
  #   { device_name = "/dev/sdb", volume_size = 100, volume_type = "gp3", encrypted = true },
  #   { device_name = "/dev/sdc", volume_size = 200, volume_type = "gp3", encrypted = true },
  # ]
}

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"  # Amazon Linux 2 — us-east-1
  instance_type = "t2.micro"

  # Root volume — always present, not driven by dynamic block
  root_block_device {
    volume_size = 20     # 20GB root — sensible default
    volume_type = "gp3"
    encrypted   = true   # Always encrypt root
  }

  # Additional EBS volumes — zero or more depending on var.ebs_volumes
  # Dev: empty list means no ebs_block_device blocks generated
  # Prod: list with entries means one block per entry
  dynamic "ebs_block_device" {
    for_each = var.ebs_volumes  # list(object) — iterates by index

    content {
      device_name = ebs_block_device.value.device_name  # Default iterator name — no rename
      volume_size = ebs_block_device.value.volume_size
      volume_type = ebs_block_device.value.volume_type
      encrypted   = ebs_block_device.value.encrypted
    }
  }

  tags = { Name = "lesson23-web", ManagedBy = "Terraform" }
}
# Dev deploy — empty list means no ebs_block_device blocks
$ terraform apply

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + root_block_device = { volume_size = 20, volume_type = "gp3", encrypted = true }
      # No ebs_block_device blocks — dynamic generated nothing from empty list
    }

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

# Prod deploy — pass volumes via -var or tfvars
$ terraform apply -var='ebs_volumes=[{"device_name":"/dev/sdb","volume_size":100,"volume_type":"gp3","encrypted":true}]'

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + root_block_device    = { volume_size = 20, volume_type = "gp3" }
      + ebs_block_device     = [{ device_name = "/dev/sdb", volume_size = 100 }]
    }

Plan: 1 to add, 0 to change, 0 to destroy.
# One ebs_block_device block generated — same resource block as dev

What just happened?

  • The dev deploy produced zero ebs_block_device blocks from the empty list. for_each = [] (an empty list converted to a set) iterates nothing. No EBS volumes are attached — just the root disk. The dynamic block in configuration is irrelevant when the collection is empty.
  • The prod deploy produced exactly one ebs_block_device block from the one-item list. The same resource block — the same configuration file — produced a different real result based entirely on the variable value. This is the power of dynamic blocks: one configuration, many valid deployments.
  • The default iterator name matches the block type. Without an explicit iterator, the default iterator name is the block type — ebs_block_device. So inside content, you write ebs_block_device.value.volume_size. This can be verbose — using a short custom iterator like iterator = vol and vol.value.volume_size is cleaner for long block type names.

When Not to Use Dynamic Blocks

Dynamic blocks are powerful, but overuse makes configurations harder to read and reason about. Every dynamic block trades explicitness for flexibility. Here are the cases where a static block is better.

When the blocks are fixed and environment-independent

If a resource always has exactly the same nested blocks in every environment — a root disk that is always 20GB, always gp3, always encrypted — write it as a static block. A dynamic block that always iterates the same fixed list adds complexity without benefit. Static blocks are self-documenting; you can read them without looking up what the variable contains.

When the nested blocks have complex, diverging content

Dynamic blocks work best when all generated blocks have the same structure — every ingress rule has the same fields. If each block needs significantly different logic — one needs a self_referencing_sg while others need cidr_blocks — static blocks with conditional arguments are clearer than a dynamic block with complex conditionals in the content.

When there will only ever be one instance

A single, always-present nested block written as a dynamic block of size one is just noise. Use a static block and save the dynamic block for when the count might legitimately be zero or variable.

Common Mistakes

Forgetting to name iterators in nested dynamic blocks

When you nest a dynamic block inside another without custom iterator names, both use each. Inside the inner content block, references to each.value resolve to the inner iteration's current item — silently dropping access to the outer iteration's data. Always add iterator = outer_name to the outer block and iterator = inner_name to the inner block when nesting.

Using the wrong block type name

The string in dynamic "BLOCK_TYPE" must exactly match the argument name used for that block type in the resource. For aws_security_group it is ingress and egress. For aws_instance it is ebs_block_device — not ebs, not ebs_volume. Check the provider documentation for the exact block type name.

Mixing dynamic and static blocks of the same type

You can have both a static ingress block and a dynamic "ingress" block in the same security group — Terraform merges them. But this pattern is confusing: some rules are explicit, others are variable. It becomes difficult to see the full rule set without evaluating the variable. If any rules need to be dynamic, make all rules dynamic through the same mechanism.

terraform console is the best tool for developing dynamic block logic

Before wiring a complex variable into a dynamic block, use terraform console to test the expressions you will use in the content block. Evaluate var.ingress_rules to see its shape. Try { for k, v in var.ingress_rules : k => v.from_port } to confirm the access pattern. Iterate over the variable mentally before committing it to the resource block. Console feedback is instant — apply feedback takes minutes.

Practice Questions

1. Inside a dynamic block, which nested block defines what each generated block contains?



2. When nesting two dynamic blocks to avoid the default "each" name conflicting between outer and inner, which argument do you add to each dynamic block?



3. A dynamic block has for_each = [] (empty list). How many nested blocks does it generate?



Quiz

1. What are the three required parts of a dynamic block?


2. You have dynamic "ebs_block_device" without an iterator argument. How do you access the volume_size field inside the content block?


3. When should you prefer a static nested block over a dynamic block?


Up Next · Lesson 24

Workspaces

Dynamic blocks let one resource handle variable content. Workspaces let one configuration deploy to multiple environments — dev, staging, prod — each with its own isolated state. Lesson 24 covers when workspaces are the right answer and when separate configurations are better.