Terraform Course
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_TYPEis the name of the nested block you are generating — for exampleingress,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.keyandeach.valueto access the current iteration's data — same as resource-level for_each. - iterator argument — optional. Overrides the default iterator name from
eachto 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 oneingressblock for each. The resource block in configuration has exactly onedynamic "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_rulesvariable — invariables.tfor aterraform.tfvarsfile — 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 ownprod.tfvarswith a larger rule set without forking the configuration. - The iterator = rule renaming is a readability choice. Without
iterator = rule, you would useeach.value.descriptioninside the content block. With it, you userule.value.description— more readable because it names the thing being iterated. This matters most when nesting dynamic blocks, where the defaulteachname 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
dynamicblock inside thecontentof anotherdynamicblock. Generates nested blocks inside each outer generated block. - iterator name collision — when outer and inner dynamic blocks both use the default
eachiterator, references inside the inner block are ambiguous — whicheach? Resolve this by giving the outer block a customiteratorname — 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 iteratorstmtmade 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.conditionslist 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 oneConditionblock. The same inner dynamic block handles both cases. - Iterator naming was essential. Without
iterator = stmton the outer block anditerator = condon the inner block, both would useeach. Inside the inner content block,each.value.testwould be ambiguous — whicheach? 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 devWhat 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 writeebs_block_device.value.volume_size. This can be verbose — using a short custom iterator likeiterator = volandvol.value.volume_sizeis 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.