Terraform doesn’t let you nest for_each directly inside a resource, but that doesn’t mean you can’t model complex loops. By precomputing combinations in locals or variables (using for-expressions), you can flatten nested data into a single iterable.
In this article, we’ll explain how to make “nested” loops the Terraform way. What we’ll cover:
- What is for_each in Terraform?
- Can you nest for_each in Terraform resources?
- How to use nested for_each in Terraform with flatten and locals
- Terraform nested for_each example: Creating multiple subnets across multiple VPCs
- Using dynamic blocks with nested for_each in Terraform
- Terraform nested for_each pitfalls and how to avoid them
for_each is a Terraform meta-argument you can use with resources, modules, and some other constructs. It lets you define multiple instances of the same resource or module in a clean, declarative way, from a map (any value type) or a set of strings. Keys must be unique and stable.
For example, this creates two buckets – one with key logs, one with key images:
resource "aws_s3_bucket" "buckets" {
for_each = {
logs = "my-logs-bucket"
images = "my-images-bucket"
}
bucket = each.value
tags = {
Name = each.key
}
}You can later reference these buckets as:
aws_s3_bucket.buckets["logs"]aws_s3_bucket.buckets["images"]
Instead of manually duplicating resource blocks or relying solely on count (which works with simple lists and indexes), for_each gives you more explicit control by creating instances keyed by meaningful values.
Dynamic blocks have their own for_each and optional iterator to avoid shadowing.
Read more: How to Use Terraform For_Each Meta-Argument
No, Terraform does not support directly nesting for_each blocks within another for_each in a single resource definition. A resource can have only one of for_each or count.
For example, this is invalid:
resource "aws_security_group_rule" "example" {
for_each = var.sg_ids
# ❌ Terraform won’t allow another nested for_each here
for_each = var.ports
...
}You only get one for_each per resource, but you can precompute combinations with for expressions in locals or variables and then loop once over the flattened structure.
You can also nest for_each inside Terraform resources using dynamic blocks. The main for_each is applied at the resource level, and nested dynamic blocks inside that resource can also use their own for_each to iterate over nested structures.
To use nested for_each in Terraform with flatten and locals, define a local variable that flattens a nested data structure into a flat list of maps, then use that in your for_each.
Let’s see some examples.
1. From nested maps → flat list → for_each on resources
Imagine an input shaped like var.envs → regions → subnets. This is easy for humans to read, but awkward to loop over directly in resources. We’ll preprocess it into a flat list of “work items,” then convert that list to a keyed map for for_each.
variable "envs" {
type = map(object({
regions = map(object({
vpc_id = string
cidr_blocks = list(string)
}))
}))
}
# Example input:
# envs = {
# dev = {
# regions = {
# eu-west-1 = {
# vpc_id = "vpc-aaaa1111"
# cidr_blocks = ["10.0.1.0/24", "10.0.2.0/24"]
# }
# us-east-1 = {
# vpc_id = "vpc-bbbb2222"
# cidr_blocks = ["10.1.1.0/24"]
# }
# }
# }
# prod = {
# regions = {
# eu-west-1 = {
# vpc_id = "vpc-cccc3333"
# cidr_blocks = ["10.2.1.0/24", "10.2.2.0/24", "10.2.3.0/24"]
# }
# }
# }
# }We first use nested for-expressions plus flatten() to produce a flat list of objects, each representing a single subnet to create (with env, region, vpc_id, and cidr_block). We then turn that list into a keyed map for for_each.
locals {
# 1) Flatten envs -> regions -> cidr_blocks into a list of objects
subnets_list = flatten([
for env_name, env in var.envs : [
for region_name, region in env.regions : [
for cidr in region.cidr_blocks : {
env = env_name
region = region_name
vpc_id = region.vpc_id
cidr_block = cidr
# you can derive naming here, consistently:
name = "${env_name}-${replace(region_name, ".", "-")}-${replace(cidr, "/", "-")}"
# optionally attach tags/labels:
tags = {
env = env_name
region = region_name
}
}
]
]
])
# 2) Re-key that list into a map with stable unique keys for for_each
# Using index in case two objects would otherwise produce identical names.
subnets_map = {
for idx, s in local.subnets_list :
"${s.name}-${idx}" => s
}
}Once normalized, for_each becomes trivial and readable. You can target any provider/resource here (below is illustrative only):
resource "aws_subnet" "this" {
for_each = local.subnets_map
vpc_id = each.value.vpc_id
cidr_block = each.value.cidr_block
# availability_zone = "eu-west-1a"
# availability_zone_id = "euw1-az1"
tags = merge(
each.value.tags,
{ Name = each.key }
)
}2. From a mixed structure with optional fields → compact/try → flat list
Sometimes, not all nested items should produce a resource (e.g., optional/disabled entries). Use a for expression with an if clause to drop null items. Build lists that can include null and remove them with a filter before you flatten().
locals {
iam_bindings = flatten([
for project_id, proj in var.projects : [
for r in proj.roles : r.enabled ? {
project = project_id
role = r.name
members = r.members
} : null
]
])
# Filter out nulls (works for objects)
iam_bindings_filtered = [for b in local.iam_bindings : b if b != null]
# Each binding will then fan out to one binding per member (if the target resource needs that)
iam_member_bindings = flatten([
for b in local.iam_bindings_filtered : [
for m in b.members : {
project = b.project
role = b.role
member = m
key = "${b.project}:${b.role}:${m}"
}
]
])
# Map for for_each
iam_member_map = { for x in local.iam_member_bindings : x.key => x }
}Now apply them with for_each:
# Example: google_project_iam_member (illustrative)
resource "google_project_iam_member" "binding" {
for_each = local.iam_member_map
project = each.value.project
role = each.value.role
member = each.value.member
}3. Nested loops inside a module call (matrix builds)
A clean way to build “matrices” such as environments × regions × sizes is to use Terraform’s built-in setproduct() and then key the results for a single for_each.
The snippet below generates the Cartesian product, turns it into a list of objects, and re-keys the list into a map with stable, unique keys. You then pass that map to a module (or resource) with for_each.
variable "environments" { type = set(string) }
variable "regions" { type = set(string) }
variable "sizes" { type = set(string) }
locals {
# Cartesian product of all dimensions
combos = [
for t in setproduct(var.environments, var.regions, var.sizes) : {
env = t[0]
region = t[1]
size = t[2]
name = "${t[0]}-${t[1]}-${t[2]}"
}
]
# Stable, unique keys for for_each
combos_map = { for i, c in local.combos : "${c.name}-${i}" => c }
}
module "nodepool" {
source = "./modules/nodepool"
for_each = local.combos_map
env = each.value.env
region = each.value.region
size = each.value.size
name = each.value.name
}Prefer predictable keys so Terraform doesn’t think items have changed when they haven’t.
Also, keep an eye on the size of each set: large inputs can quickly expand into many module instances.
Imagine you want to provision multiple VPCs, and inside each VPC, you also want to create a set of subnets. Instead of writing repetitive Terraform code for each VPC and each subnet, you can nest a for_each to dynamically build them from structured input.
Step 1: Define input variables
You might define a variable that contains a mapping of VPCs and their associated subnets. This creates a nested data structure that Terraform can iterate over.
variable "vpc_config" {
type = map(object({
cidr_block = string
subnets = map(string) # subnet_name => cidr_block
}))
}
# Example input
vpc_config = {
vpc1 = {
cidr_block = "10.0.0.0/16"
subnets = {
public-1 = "10.0.1.0/24"
private-1 = "10.0.2.0/24"
}
}
vpc2 = {
cidr_block = "10.1.0.0/16"
subnets = {
public-1 = "10.1.1.0/24"
private-1 = "10.1.2.0/24"
private-2 = "10.1.3.0/24"
}
}
}Here, each VPC has its own CIDR block and a map of subnets.
Step 2: Create the VPCs with for_each
You can loop over the top-level VPC map to create the VPC resources.
resource "aws_vpc" "this" {
for_each = var.vpc_config
cidr_block = each.value.cidr_block
}Now, Terraform will generate one aws_vpc resource per entry in the vpc_config map.
Step 3: Nested for_each for Subnets
Inside each VPC, you need subnets. Here’s where nested for_each comes into play. Since subnets are defined per VPC, you can use a flattening technique to merge both levels into a single iterable structure.
locals {
subnet_map = merge([
for vpc_key, vpc_value in var.vpc_config : {
for subnet_name, subnet_cidr in vpc_value.subnets :
"${vpc_key}-${subnet_name}" => {
vpc_id = aws_vpc.this[vpc_key].id
cidr_block = subnet_cidr
}
}
]...)
}This local.subnet_map flattens the nested maps into a single map where each key combines the VPC and subnet name (e.g., vpc1-public-1).
Then, you can loop over it:
resource "aws_subnet" "this" {
for_each = local.subnet_map
vpc_id = each.value.vpc_id
cidr_block = each.value.cidr_block
}Step 4: Understanding the flow
Let’s review what has happened:
- Input data structure – Provides hierarchical configuration.
- Outer
for_each(VPCs) – Iterates over VPCs and creates them. - Inner
for_each(Subnets) – Flattens subnet definitions across all VPCs, then creates them dynamically.
Nested for_each in Terraform often requires a flattening step (using for expressions and merge), since Terraform resources cannot directly use double loops. By combining keys, you give Terraform a single map to iterate over while preserving the hierarchy.
Terraform’s dynamic blocks let you generate nested blocks conditionally or repeatedly from variables. This can be handy when the shape of a resource’s configuration is data-driven.
A common pattern is one for_each per nesting level, producing blocks like statement { ... } which themselves contain repeated principals { ... } and condition { ... } blocks. Dynamic blocks can have their own for_each and iterator names.
Below, we’ll build an IAM policy document dynamically from an input variable. This pattern translates well to other providers that have multi-level nested blocks (load balancer rules, firewall rules with sub-matchers, etc.).
The data model
We’ll accept a list of “statements.” Each statement can contain many principals and many conditions. Modeling it clearly up front makes the dynamic logic straightforward.
variable "statements" {
description = "Policy statements to include"
type = list(object({
sid = optional(string)
effect = string # "Allow" or "Deny"
actions = list(string) # e.g., ["s3:GetObject", "s3:PutObject"]
resources = list(string) # e.g., ["arn:aws:s3:::my-bucket/*"]
principals = optional(list(object({
type = string # e.g., "AWS", "Service"
identifiers = list(string) # e.g., ["arn:aws:iam::123456789012:root"]
})), [])
conditions = optional(list(object({
test = string # e.g., "StringEquals"
variable = string # e.g., "s3:prefix"
values = list(string) # e.g., ["home/"]
})), [])
}))
}A key tip: default optional collections to an empty list. That way, your dynamic blocks can iterate safely without extra length(...) > 0 guards.
Generating nested blocks with dynamic and for_each
In aws_iam_policy_document, we’ll create a statement block per item in var.statements. Inside each statement, we’ll create zero or more principals and condition blocks.
To avoid confusion between the outer and inner each objects, use the iterator argument to give them meaningful names (e.g., stmt, prn, cond).
data "aws_iam_policy_document" "this" {
dynamic "statement" {
for_each = var.statements
iterator = stmt
content {
sid = try(stmt.value.sid, null)
effect = stmt.value.effect
actions = stmt.value.actions
resources = stmt.value.resources
# Nested dynamic: principals
dynamic "principals" {
for_each = try(stmt.value.principals, [])
iterator = prn
content {
type = prn.value.type
identifiers = prn.value.identifiers
}
}
# Nested dynamic: condition
dynamic "condition" {
for_each = try(stmt.value.conditions, [])
iterator = cond
content {
test = cond.value.test
variable = cond.value.variable
values = cond.value.values
}
}
}
}
}A few things to notice:
dynamic "<blockname>"must match the exact nested block type required by the resource or data source.content { ... }contains the attributes you’d normally put directly into that block.- Each nested
dynamichas its ownfor_each. You can freely nest multiple levels. iterator = <name>gives you<name>.keyand<name>.value, preventing accidental shadowing of outereach.
Example input and output
Given this input:
locals {
example_statements = [
{
sid = "ReadOnlyBucket"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::my-bucket/*"]
principals = [
{
type = "AWS"
identifiers = ["arn:aws:iam::123456789012:root"]
}
]
conditions = [
{
test = "StringEquals"
variable = "s3:prefix"
values = ["public/"]
}
]
},
{
effect = "Deny"
actions = ["s3:DeleteObject"]
resources = ["arn:aws:s3:::my-bucket/*"]
principals = []
conditions = []
}
]
}
# Pass into the variable
module "policy" {
source = "./modules/policy" # wherever the data block above lives
statements = local.example_statements
}Terraform will render two statement blocks, one with nested principals and condition, and one with neither, precisely matching the shape of your data. You can then attach the JSON to a role or policy:
resource "aws_iam_policy" "this" {
name = "example-dynamic-policy"
policy = data.aws_iam_policy_document.this.json
}When not to use dynamic blocks
dynamic is best when you truly need zero-to-many nested blocks. If you’re only toggling simple attributes, use a regular conditional expression and avoid dynamic entirely. In this example, the resource flips a single setting with a straightforward conditional and no extra ceremony:
resource "example_service" "x" {
name = "demo"
setting = var.enable_optional ? "on" : "off"
}If the entire child block is optional, control the block’s presence with for_each on the dynamic block rather than trying to use null inside the block. The pattern for_each = var.enable_optional ? [1] : [] cleanly adds or removes the block:
resource "example_service" "x" {
name = "demo"
dynamic "optional_block" {
for_each = var.enable_optional ? [1] : []
content {
setting = "on"
}
}
}When you actually have many repeated blocks, dynamic shines because it can iterate over a list of inputs and render one child block per item. The example below drives a repeated rule block from a variable, using iterator to keep the inner loop clear:
variable "rules" {
type = list(object({
port = number
protocol = string
}))
default = []
}
resource "example_firewall" "fw" {
name = "demo"
dynamic "rule" {
for_each = var.rules
iterator = r
content {
port = r.value.port
protocol = r.value.protocol
}
}
}As a rule of thumb: use plain conditionals for simple attribute flips, use a gated dynamic block when a single nested block is optional, and use dynamic with a list when you need to render many repeated child blocks.
Using nested for_each in Terraform can introduce issues related to resource indexing, duplication, and state consistency. The main pitfalls are:
- Unstable or inconsistent keys: When nesting
for_each, especially across resources or modules, using complex or computed keys (like maps from dynamic data) can cause Terraform to interpret them as changed, triggering unnecessary recreation. - Hidden dependencies: Nested
for_eachconstructs often mask dependencies between resources, leading to incorrect apply order or failure due to missing inputs. - State drift on structural changes: Changing the shape or key structure of the nested loop (e.g., modifying a map or list of maps) can force resource destruction and recreation, even if actual values are unchanged.
To avoid these issues:
- Use static, predictable keys for iteration (e.g., predefined maps with stable identifiers). Avoid ephemeral values in keys.
- Break out nested loops into separate resources or modules to simplify the dependency graph.
- Prefer flattened data structures where possible to avoid deep nesting.
- Always explicitly define resource names using meaningful keys to reduce accidental drift.
Terraform is really powerful, but to achieve an end-to-end secure GitOps approach, you need to use a product that can run your Terraform workflows. Spacelift takes managing Terraform to the next level by giving you access to a powerful CI/CD workflow and unlocking features such as:
- Policies (based on Open Policy Agent) – You can control how many approvals you need for runs, what kind of resources you can create, and what kind of parameters these resources can have, and you can also control the behavior when a pull request is open or merged.
- Multi-IaC workflows – Combine Terraform with Kubernetes, Ansible, and other infrastructure-as-code (IaC) tools such as OpenTofu, Pulumi, and CloudFormation, create dependencies among them, and share outputs
- Build self-service infrastructure – You can use Blueprints to build self-service infrastructure; simply complete a form to provision infrastructure based on Terraform and other supported tools.
- Integrations with any third-party tools – You can integrate with your favorite third-party tools and even build policies for them. For example, see how to integrate security tools in your workflows using Custom Inputs.
Spacelift enables you to create private workers inside your infrastructure, which helps you execute Spacelift-related workflows on your end. Read the documentation for more information on configuring private workers.
You can check it out for free by creating a trial account or booking a demo with one of our engineers.
In Terraform, you can’t literally “nest” a for_each directly inside another for_each within the same resource block, but you can achieve the same effect by combining data structures and iterating over them. Terraform only allows one for_each per resource, but you can precompute nested combinations using expressions, maps, or flattening lists before passing them into a resource.
When you need a Cartesian product, prefer setproduct() over manual nesting. Use stable keys for for_each.
Note: New versions of Terraform are placed under the BUSL license, but everything created before version 1.5.x stays open-source. OpenTofu is an open-source version of Terraform that expands on Terraform’s existing concepts and offerings. It is a viable alternative to HashiCorp’s Terraform, being forked from Terraform version 1.5.6.
Manage Terraform better with Spacelift
Build more complex workflows based on Terraform using policy as code, programmatic configuration, context sharing, drift detection, resource visualization and many more.
Frequently asked questions
Can I use multiple for_each loops directly in a Terraform resource?
A single Terraform resource block can only use one
for_eachorcountat a time. You cannot define multiplefor_eachloops directly in a single resource. However, you can use aforexpression inside a map or set to create a combined input for a singlefor_each.What’s the difference between for_each and a nested for expression?
for_eachis a meta-argument used to create multiple instances of a resource or module from a map or set. A nestedforexpression is used within expressions (like variables, locals, or outputs) to build complex lists or maps through iteration, including filtering or transformation.When should I use a dynamic block with for_each for nested arguments?
Use a
dynamicblock withfor_eachwhen you need to define nested blocks in Terraform where the number of nested arguments is variable or depends on input values. This is common for resources likeaws_security_grouprules orlistenerblocks inaws_lb, where the structure repeats but with different values.
