Abhishek Sah

Managing Infrastructure at Scale With Terraform

March 13, 2026

What this post covers:

  • Why for_each exists and how it avoids the index-shifting problem that count has
  • How sets, maps, and maps of objects work with for_each, and why lists need toset()
  • The flatten-then-map pattern for turning nested data into flat maps
  • Dynamic blocks, the known-values constraint, and other gotchas

When your Terraform codebase manages a handful of resources, almost any approach works. But as infrastructure grows — more services, more environments, more accounts — you start needing ten, fifty, or a hundred copies of the same resource with slightly different config. At that point, how you loop over resources decides whether adding a new service takes one line or a hundred, and whether removing one accidentally destroys something else.

Terraform gives you two ways to create multiple resources: count and for_each. This post is about for_each — how it works, where it helps at scale, and where it gets tricky.

Why Not count

The count argument identifies resources by their position in a list:

variable "bucket_names" {
  default = ["logs", "data", "backups"]
}

resource "aws_s3_bucket" "this" {
  count  = length(var.bucket_names)
  bucket = "mycompany-${var.bucket_names[count.index]}"
}

In state, these resources are tracked as aws_s3_bucket.this[0], aws_s3_bucket.this[1], and aws_s3_bucket.this[2].

If you remove “data” from the middle of the list, “backups” shifts from index 2 to index 1. Terraform sees that index 1 has changed and that index 2 has disappeared — so it plans a destroy and recreate of the backups bucket. In production, that could mean deleting a bucket that holds customer data.

How for_each fixes this

Instead of tracking resources by position, for_each identifies each one by a name:

resource "aws_s3_bucket" "this" {
  for_each = toset(["logs", "data", "backups"])
  bucket   = "mycompany-${each.value}"
}

In state, these become aws_s3_bucket.this["logs"], aws_s3_bucket.this["data"], and aws_s3_bucket.this["backups"]. If you remove “data”, only that resource gets destroyed, and the other two stay exactly as they are.

The for_each argument accepts either a set or a map, but it does not accept a list directly. You can convert a list with toset(). When you use a set, each.key and each.value refer to the same thing. When you use a map, each.key gives you the key and each.value gives you the value.

Maps

Maps are where for_each gets more useful, because you can attach different configuration to each item:

variable "buckets" {
  default = {
    "logs"    = "us-east-1"
    "data"    = "us-west-2"
    "backups" = "eu-west-1"
  }
}

resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = "mycompany-${each.key}"

  tags = {
    Region = each.value
  }
}

To add a bucket, you add one line to the map. To remove one, you delete a line. Terraform figures out the rest.

Maps of Objects

In most cases, each resource needs more than a single value. You can handle that with a map of objects:

variable "services" {
  type = map(object({
    port     = number
    replicas = number
    memory   = optional(string, "256Mi")
  }))

  default = {
    "api"    = { port = 8080, replicas = 3, memory = "512Mi" }
    "worker" = { port = 9090, replicas = 2 }
  }
}

resource "kubernetes_deployment" "this" {
  for_each = var.services

  metadata {
    name = each.key
  }

  spec {
    replicas = each.value.replicas

    template {
      spec {
        container {
          name   = each.key
          image  = "myregistry/${each.key}:latest"
          port   = each.value.port
          memory = each.value.memory
        }
      }
    }
  }
}

Caveat: Every object in the map must have the same shape. If “api” has a port field, then “worker” needs one too. You can use optional() with a default value for fields that not every entry needs.

Referencing for_each Resources

To reference a specific instance, you use the key in square brackets:

output "api_name" {
  value = kubernetes_deployment.this["api"].metadata[0].name
}

To reference all instances at once, you use a for expression to build a new map:

output "all_names" {
  value = { for k, v in kubernetes_deployment.this : k => v.metadata[0].name }
}

Caveat: You cannot pass kubernetes_deployment.this directly as an output. Terraform expects you to transform it with a for expression. The splat syntax ([*]) that works with count does not apply to for_each resources.

“Known Values” Constraint

The keys you pass to for_each must be known at plan time, which means they cannot come from the output of another resource. For example, this fails:

resource "aws_route_table_association" "this" {
  for_each  = { for s in aws_subnet.this : s.id => s }
  subnet_id = each.key
}

The subnet IDs do not exist until Terraform actually creates them, so it cannot determine the keys during the plan phase. The fix is to drive both resources from the same input variable:

variable "subnet_config" {
  default = {
    "subnet-a" = { az = "us-east-1a", cidr = "10.0.1.0/24" }
    "subnet-b" = { az = "us-east-1b", cidr = "10.0.2.0/24" }
  }
}

resource "aws_subnet" "this" {
  for_each          = var.subnet_config
  availability_zone = each.value.az
  cidr_block        = each.value.cidr
}

resource "aws_route_table_association" "this" {
  for_each       = var.subnet_config
  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.main.id
}

Since both resources share the same map, the keys come from a variable and are known at plan time. This is worth doing on purpose — when one map drives multiple resources, the code becomes easier to follow and less likely to break.

Flattening Nested Data

When your data is nested, you need to turn it into a flat map before for_each can work with it. For example, say you have multiple users and each of them needs several IAM policy attachments:

variable "user_policies" {
  default = {
    "alice" = ["arn:aws:iam::policy/ReadOnly", "arn:aws:iam::policy/S3Full"]
    "bob"   = ["arn:aws:iam::policy/ReadOnly"]
  }
}

The transformation happens in three steps. First, nested for loops produce a list of lists. Then, flatten collapses them into a single flat list. Finally, a for expression converts that list into a map with composite keys:

locals {
  user_policy_pairs = flatten([
    for user, policies in var.user_policies : [
      for policy in policies : {
        user   = user
        policy = policy
      }
    ]
  ])

  user_policy_map = {
    for pair in local.user_policy_pairs :
    "${pair.user}-${basename(pair.policy)}" => pair
  }
}

resource "aws_iam_user_policy_attachment" "this" {
  for_each   = local.user_policy_map
  user       = each.value.user
  policy_arn = each.value.policy
}

Caveat: The composite key must be unique across all entries. If you change the key format later, Terraform will treat every affected resource as a destroy-and-create.

Caveat: Make sure you carry the outer key (like the user name) into the flattened object. If you drop it during flattening, you will not be able to reference it in the resource block later.

Dynamic Blocks Inside for_each

There are situations where you need to loop across resources and also loop within a single resource. The outer for_each handles the first part, and a dynamic block handles the second:

variable "security_groups" {
  default = {
    "web" = {
      ingress_rules = [
        { port = 80, cidr = "0.0.0.0/0" },
        { port = 443, cidr = "0.0.0.0/0" },
      ]
    }
    "api" = {
      ingress_rules = [
        { port = 8080, cidr = "10.0.0.0/8" },
      ]
    }
  }
}

resource "aws_security_group" "this" {
  for_each = var.security_groups
  name     = each.key

  dynamic "ingress" {
    for_each = each.value.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [ingress.value.cidr]
    }
  }
}

Caveat: Inside the dynamic block, the iterator is called ingress.value rather than each.value. It takes its name from the block label. The each variable still refers to the outer for_each on the resource itself.

Putting It Together: Multi-Environment EKS Node Groups

This pattern is close to what you would see in a real codebase where a team runs Kubernetes across staging and production. A single variable drives all the node groups:

variable "environments" {
  type = map(object({
    cluster_name = string
    node_groups = map(object({
      instance_type = string
      min_size      = number
      max_size      = number
      desired_size  = number
      disk_size     = number
      labels        = map(string)
      taints = list(object({
        key    = string
        value  = string
        effect = string
      }))
    }))
  }))
}

locals {
  all_node_groups = flatten([
    for env_name, env in var.environments : [
      for ng_name, ng in env.node_groups : {
        env_name     = env_name
        cluster_name = env.cluster_name
        ng_name      = ng_name
        ng_config    = ng
      }
    ]
  ])

  node_group_map = {
    for ng in local.all_node_groups :
    "${ng.env_name}-${ng.ng_name}" => ng
  }
}

resource "aws_eks_node_group" "this" {
  for_each        = local.node_group_map
  cluster_name    = each.value.cluster_name
  node_group_name = each.value.ng_name
  instance_types  = [each.value.ng_config.instance_type]
  disk_size       = each.value.ng_config.disk_size

  scaling_config {
    min_size     = each.value.ng_config.min_size
    max_size     = each.value.ng_config.max_size
    desired_size = each.value.ng_config.desired_size
  }

  labels = merge(each.value.ng_config.labels, {
    environment = each.value.env_name
    node_group  = each.value.ng_name
  })

  dynamic "taint" {
    for_each = each.value.ng_config.taints
    content {
      key    = taint.value.key
      value  = taint.value.value
      effect = taint.value.effect
    }
  }
}

To add an environment or a node group, you add an entry to the map. The flatten-then-map step produces keys like “production-observability” and “staging-general”, and Terraform manages each one independently in state.

Summary

count vs for_each: You should use count only for conditional creation (count = var.enabled ? 1 : 0), and reach for for_each whenever you need multiple instances.

Input types: The for_each argument takes a set or a map. If you have a list, you can convert it with toset().

Known values: All keys must be known at plan time, so you should drive them from variables rather than resource outputs.

Flattening: Nested data needs to be flattened into a map with composite keys, and you should carry context from the outer loops into the flattened objects so it is available in the resource block.

State keys are identity: If you change a key, Terraform will destroy and recreate that resource. Choose your keys carefully, and use terraform state mv when you need to rename one.

Dynamic blocks: The iterator inside a dynamic block is named after the block label, not each, so keep track of which variable refers to which loop.


Abhishek Sah

Written by Abhishek Sah
👨‍💻Ψ ☮️
Twitter