Infrastructure engineers and DevOps professionals tired of repetitive code will find Terraform loops essential for scaling cloud deployments efficiently. This guide walks you through loop techniques that automatically create multiple similar resources without copy-pasting code. You’ll learn how the count parameter simplifies basic repetition, how for_each offers more flexibility with dynamic inputs, and how to implement practical loop patterns in your production infrastructure. Ready to make your Terraform code more concise and powerful? Let’s dive in.
Understanding Terraform Loops Fundamentals
The evolution from copy-paste to automation
Remember when you first started with Terraform? There you were, copying and pasting resource blocks like a mad person. Need ten similar EC2 instances? Copy-paste ten times and change the names manually. It worked, but it wasn’t pretty.
The reality is, most of us started this way. But as our infrastructure grew, so did the headaches from this approach. One tiny change meant updating dozens of near-identical code blocks. One missed update and boom—your infrastructure is inconsistent.
Terraform’s loop constructs emerged from this pain. They’re not just convenient—they’re sanity-preserving.
Why loops are essential for infrastructure scalability
Infrastructure that can’t scale is infrastructure that will fail. Period.
When your startup suddenly needs to deploy across three new regions, or your app needs twenty more servers instead of two, manual approaches collapse under their own weight.
Loops make your code:
- Readable – 5 lines instead of 50
- Maintainable – Change one parameter, affect all resources
- Error-resistant – Less manual work = fewer typos
I’ve seen teams spend days updating copy-pasted configurations that could’ve been changed in minutes with proper loop structures.
Types of loops available in Terraform
Terraform doesn’t have traditional programming loops, but offers powerful alternatives:
Count parameter
The OG loop mechanism. Simple but effective for creating multiple identical resources.
resource "aws_instance" "server" {
count = 5
ami = "ami-abc123"
# Each instance gets a unique index
}
For_each expression
The problem-solver for creating resources from maps or sets of data.
resource "aws_iam_user" "example" {
for_each = toset(["bob", "alice", "carlos"])
name = each.value
}
Dynamic blocks
Your secret weapon for repeated nested blocks within resource configurations.
resource "aws_security_group" "example" {
dynamic "ingress" {
for_each = var.service_ports
content {
port = ingress.value
protocol = "tcp"
}
}
}
When to use each loop type
Choosing the wrong loop is like bringing a knife to a gunfight—technically a weapon, but not the right one.
Use count when:
- Creating multiple identical resources
- Simple numeric iteration is enough
- You need the index number
Use for_each when:
- Working with maps or sets of data
- Resources need unique attributes beyond just an index
- You need to avoid the “index shifting problem” that plagues count
Use dynamic blocks when:
- You need to generate multiple nested blocks
- Dealing with repetitive configuration within a single resource
- Managing complex resource attributes like rules or policies
The worst mistake I see? Using count when for_each would be clearer. When you remove an item from the middle of a count loop, everything shifts down—potentially wreaking havoc on your state file.
Mastering the Count Parameter
Basic count syntax and usage
Ever stared at repetitive Terraform code and thought, “There’s gotta be a better way”? The count parameter is your answer.
At its simplest, count is just a number you add to resources:
resource "aws_instance" "server" {
count = 3
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
tags = {
Name = "Server ${count.index}"
}
}
That’s it. This creates three identical EC2 instances. The magic is in count.index
– it gives you the current position (0, 1, 2).
Dynamic resource creation with count
Count really shines when paired with variables:
variable "server_names" {
type = list(string)
default = ["web", "app", "db"]
}
resource "aws_instance" "server" {
count = length(var.server_names)
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
tags = {
Name = var.server_names[count.index]
}
}
Now you’re creating servers dynamically based on a list. Add a name, get a server. Remove one, it’s gone on the next apply.
Handling count limitations
Count isn’t perfect. The biggest gotcha? It’s position-based. If you remove an item from the middle of your list, Terraform sees everything after it as “changed” because the indexes shift.
Also, count forces you to use the same config for all instances. If you need varied configurations, for_each is often better.
Working around count limitations:
# Instead of direct mapping with count.index
resource "aws_instance" "server" {
count = length(var.server_configs)
ami = var.server_configs[count.index].ami
instance_type = var.server_configs[count.index].type
}
Real-world examples of count implementation
Production-ready patterns:
# Create subnets across multiple AZs
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "Public-${count.index + 1}"
}
}
Or conditional resource creation:
# Create backup only in production
resource "aws_backup_plan" "example" {
count = var.environment == "production" ? 1 : 0
name = "production-backup-plan"
# configuration...
}
Troubleshooting common count issues
When count gives you headaches:
- The dreaded “Error: reference to count in non-counted context”
- You can’t use count.index where there’s no count. Check your resource blocks.
- Resources recreating unexpectedly
- Look for list order changes. Consider using for_each instead.
- Referencing counted resources
- Remember to use
aws_instance.server[0]
notaws_instance.server
when referencing.
- Remember to use
- State file corruption
- When manipulating count resources, use
terraform state mv
to rearrange things safely.
- When manipulating count resources, use
The count parameter isn’t fancy, but it’s a workhorse that dramatically reduces code duplication.
Leveraging For_Each for Advanced Iteration
For_each vs. count: choosing the right approach
When it comes to creating multiple similar resources in Terraform, you’ve got two main options: count
and for_each
. But choosing between them isn’t always straightforward.
count
is like your trusty old hammer – simple but sometimes limited:
- Works with simple lists
- Resources get indexed numerically (0, 1, 2…)
- Great for identical resources
- Problematic when you remove items from the middle
for_each
is your Swiss Army knife:
- Works with maps or sets
- Resources are identified by keys, not indices
- Perfect for resources with unique configurations
- Handles additions/removals gracefully
Here’s a quick comparison:
Feature | count | for_each |
---|---|---|
Data structure | List | Map or Set |
Resource addressing | Numeric index | String key |
Resource removal | Can cause ripple effect | Targeted removal |
Configuration variations | Limited | Extensive |
The gotcha with count
? Remove the second item in a list of five, and Terraform will destroy and recreate resources 3-5. Ouch!
With for_each
, you’re working with named keys, so removing “server2” only affects that resource. Everything else stays put.
Working with maps and sets in for_each
Maps and sets give for_each
its superpowers. A map is your go-to when you need different configurations for each resource.
resource "aws_instance" "servers" {
for_each = {
web = {
instance_type = "t3.micro"
ami = "ami-123456"
}
app = {
instance_type = "t3.medium"
ami = "ami-789012"
}
}
instance_type = each.value.instance_type
ami = each.value.ami
tags = {
Name = "server-${each.key}"
}
}
Sets work great when you just need a simple list of unique values:
resource "aws_iam_user" "developers" {
for_each = toset(["alex", "sam", "jordan"])
name = each.value
}
Inside your resource block, you access:
each.key
: The map key or set valueeach.value
: The map value (or same as key for sets)
Creating multiple resources with unique configurations
This is where for_each
really shines. Need different sized VMs for different environments? Different tags for different teams? No problem.
locals {
subnets = {
public = {
cidr_block = "10.0.1.0/24"
az = "us-east-1a"
public = true
}
private = {
cidr_block = "10.0.2.0/24"
az = "us-east-1b"
public = false
}
database = {
cidr_block = "10.0.3.0/24"
az = "us-east-1c"
public = false
}
}
}
resource "aws_subnet" "this" {
for_each = local.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr_block
availability_zone = each.value.az
map_public_ip_on_launch = each.value.public
tags = {
Name = "${each.key}-subnet"
Type = each.value.public ? "public" : "private"
}
}
The magic here is that you’re creating all your infrastructure from a single data structure. Need to add another subnet? Just update the locals
block.
Updating and modifying resources with for_each
for_each
handles resource changes more gracefully than count
. When you update your map or set, Terraform knows exactly which resources to add, modify, or remove.
Adding a new entry? Terraform creates just that resource.
Modifying an entry? Terraform updates just that resource.
Removing an entry? Terraform destroys just that resource.
# Original
locals {
instances = {
small = "t3.micro"
medium = "t3.medium"
}
}
# Updated (added large)
locals {
instances = {
small = "t3.micro"
medium = "t3.medium"
large = "t3.large" # Only this gets created
}
}
The trick is to structure your code around data, not logic. Put your configurations in maps, then let for_each
do the heavy lifting.
Want to temporarily disable a resource? Just comment out its entry in your map – no need to touch the resource block itself.
The Power of Dynamic Blocks
Simplifying nested configurations
Dynamic blocks are your secret weapon when facing the nightmare of repetitive, nested configurations in Terraform. They work like magic for resources that contain complex nested blocks – think AWS security groups with multiple ingress rules or Azure VMs with multiple network interfaces.
Here’s a quick before-and-after that’ll make you a believer:
# Without dynamic blocks (repetitive nightmare)
resource "aws_security_group" "example" {
name = "example"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
# With dynamic blocks (clean and maintainable)
resource "aws_security_group" "example" {
name = "example"
dynamic "ingress" {
for_each = var.service_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
The difference is night and day. Your code shrinks dramatically, and changes become a matter of updating variables rather than copy-pasting blocks.
Iterating over complex data structures
Dynamic blocks truly shine when handling complex data structures like maps and objects. You’re not limited to simple lists anymore.
Got a nested map of configuration settings? No problem:
locals {
network_interfaces = {
primary = {
subnet_id = "subnet-123"
private_ip = "10.0.1.10"
security_groups = ["sg-123", "sg-456"]
},
secondary = {
subnet_id = "subnet-456"
private_ip = "10.0.2.10"
security_groups = ["sg-789"]
}
}
}
resource "aws_instance" "web" {
ami = "ami-123456"
instance_type = "t3.micro"
dynamic "network_interface" {
for_each = local.network_interfaces
content {
subnet_id = network_interface.value.subnet_id
private_ip = network_interface.value.private_ip
security_groups = network_interface.value.security_groups
}
}
}
This approach works beautifully for any nested configuration pattern. You can even nest dynamic blocks within dynamic blocks for those really complex scenarios.
Performance considerations when using dynamic blocks
Dynamic blocks are powerful but come with a few gotchas.
First off, they can make your Terraform plans harder to read. When you’ve got dozens of resources being generated from a single dynamic block, reviewing changes gets tricky.
Memory usage can also spike with large iterations. I once crashed Terraform trying to create 1,000+ IAM policies with a single dynamic block. Lesson learned.
Some practical tips:
- Keep your iterations reasonable (under 100 if possible)
- Use splat expressions for simpler cases where appropriate
- Consider breaking extremely large configurations into separate modules
- Test your loops with small numbers first, then scale up
Remember that Terraform loads everything into memory during planning. That super-efficient dynamic block creating thousands of resources? It might bring your deployment to its knees.
Practical Loop Patterns for Production Infrastructure
A. Creating multi-region deployments
Nobody wants to build the same cloud infrastructure twice. But for high-availability and disaster recovery, you need resources in multiple regions. This is where Terraform loops shine.
Instead of copying and pasting code blocks for each region, use for_each
with a set of regions:
locals {
regions = ["us-west-1", "us-east-1", "eu-west-1"]
}
module "vpc" {
for_each = toset(local.regions)
source = "./modules/vpc"
region = each.key
cidr = var.regional_cidrs[each.key]
}
Now you’re deploying identical infrastructure across three regions with a single code block. Adding a fourth region? Just add it to the list.
B. Managing environment configurations (dev, staging, prod)
The worst thing about maintaining dev, staging, and prod environments is keeping them consistent while allowing for necessary differences.
Create a map of environment configurations:
locals {
environments = {
dev = {
instance_type = "t3.small"
instance_count = 1
enable_backups = false
}
staging = {
instance_type = "t3.medium"
instance_count = 2
enable_backups = true
}
prod = {
instance_type = "m5.large"
instance_count = 3
enable_backups = true
}
}
}
module "environment" {
for_each = local.environments
source = "./modules/app-environment"
name = each.key
instance_type = each.value.instance_type
instance_count = each.value.instance_count
enable_backups = each.value.enable_backups
}
C. User management automation
Managing IAM users manually is a nightmare. Let’s fix that with loops:
locals {
developers = {
"alex" = ["project-a", "project-b"]
"jamie" = ["project-a", "project-c"]
"taylor" = ["project-b", "project-c"]
}
}
resource "aws_iam_user" "team" {
for_each = local.developers
name = each.key
}
resource "aws_iam_user_policy_attachment" "project_access" {
for_each = {
for pair in flatten([
for user, projects in local.developers : [
for project in projects : {
user = user
project = project
}
]
]) : "${pair.user}-${pair.project}" => pair
}
user = aws_iam_user.team[each.value.user].name
policy_arn = aws_iam_policy.project_policies[each.value.project].arn
}
D. Implementing consistent tagging strategies
Tags make resources traceable and accountable. But typing them out repeatedly? Pure tedium.
locals {
common_tags = {
Environment = terraform.workspace
Project = var.project_name
Owner = "DevOps Team"
ManagedBy = "Terraform"
}
resource_tags = {
"ec2" = merge(local.common_tags, { Backup = "Daily" })
"rds" = merge(local.common_tags, { Backup = "Daily", Encrypted = "True" })
"s3" = merge(local.common_tags, { PublicAccess = "False" })
}
}
resource "aws_instance" "app_servers" {
count = var.instance_count
ami = var.ami_id
instance_type = var.instance_type
tags = local.resource_tags["ec2"]
}
Testing and Debugging Loop Constructs
A. Visualizing loop iterations with terraform plan
The terraform plan
command is your first line of defense when working with loops. It shows you exactly what resources will be created, modified, or destroyed before you commit to anything.
When you’re working with a count
loop, Terraform shows each instance with an index:
# aws_instance.server[0] will be created
# aws_instance.server[1] will be created
# aws_instance.server[2] will be created
With for_each
, you’ll see the key used for each resource:
# aws_instance.server["web"] will be created
# aws_instance.server["api"] will be created
# aws_instance.server["db"] will be created
This output is crucial for spotting issues before they happen. Check for unexpected items in the loop or missing resources you thought would be created.
B. Common loop pitfalls and how to avoid them
Look, loops seem simple until they’re not. Here are the traps I see teams fall into constantly:
- Dependency hell: Resources in loops depending on other looped resources can create circular dependencies. Break these by using
depends_on
or restructuring your code. - Count vs for_each confusion: Using
count
when the order might change can wreak havoc. If you remove an item from the middle of a list, everything after it shifts down by one index, causing Terraform to destroy and recreate resources unnecessarily. - Dynamic block overuse: Nested loops inside dynamic blocks get unreadable fast. Keep them simple or extract complex logic into local variables.
- Loop variable scope issues: Variables inside loops have limited scope. Store shared calculations outside the loop.
C. Strategies for refactoring copy-paste code into loops
Spot duplicate code in your Terraform? That’s a loop waiting to happen. Here’s how to transform it:
- Identify the pattern: Look for resources that differ only in a few values like names, tags, or sizes.
- Extract variables: Pull out the differences into a map or list structure:
locals {
instances = {
"web" = { size = "t3.micro", zone = "us-west-1a" },
"api" = { size = "t3.small", zone = "us-west-1b" },
"db" = { size = "t3.medium", zone = "us-west-1c" }
}
}
- Apply the loop: Replace multiple resource blocks with a single looped version:
resource "aws_instance" "server" {
for_each = local.instances
instance_type = each.value.size
availability_zone = each.value.zone
tags = {
Name = each.key
}
}
D. Ensuring idempotency in looped resources
Idempotency means you can run the same code multiple times without different results. With loops, this gets tricky.
The golden rule: avoid using functions that generate random or time-based values inside loops. These will change on every run, breaking idempotency.
Bad example:
resource "aws_instance" "server" {
count = 3
tags = {
Name = "server-${timestamp()}" # DON'T DO THIS
}
}
Instead, use stable identifiers:
resource "aws_instance" "server" {
count = 3
tags = {
Name = "server-${count.index}"
}
}
Another tip: when using for_each
with computed values, use the toset()
or tomap()
functions to ensure consistent ordering:
resource "aws_instance" "server" {
for_each = toset(local.server_names)
# ...
}
This prevents Terraform from constantly recreating resources due to changes in the iteration order.
Terraform loops transform how infrastructure is coded, turning repetitive declarations into elegant, maintainable automation. By leveraging count parameters for simple iterations, for_each for complex resource mapping, and dynamic blocks for nested configurations, you can dramatically reduce code duplication while increasing your infrastructure’s flexibility. The practical patterns explored show how these constructs not only simplify your codebase but also enhance its readability and scalability in production environments.
As you continue your Terraform journey, invest time in mastering these loop constructs and their testing methodologies. The initial learning curve pays dividends through more robust, DRY infrastructure code that adapts to changing requirements with minimal modifications. Start by refactoring existing repetitive configurations using the appropriate loop pattern, and you’ll quickly experience how writing less code actually gives you more control over your infrastructure automation.