Advanced Terraform

count, for_each & depends_on

● Intermediate ⏱ 25 min read terraform

Meta-arguments are special arguments that apply to any resource or module block, regardless of its type. They control how Terraform creates, updates, and destroys resources — letting you create multiple instances from one block, express explicit ordering dependencies, and fine-tune replacement behavior. They're what separate beginner configurations from production-grade ones.

What Are Meta-Arguments?

Terraform resource blocks normally describe exactly one infrastructure object. Meta-arguments extend what a single block can do:

Meta-argumentPurpose
countCreate N copies of a resource using an integer
for_eachCreate one copy per element in a map or set
depends_onDeclare explicit ordering dependencies
lifecycleControl create/update/destroy behavior
providerSelect a non-default provider configuration

count

count accepts any non-negative integer. Terraform creates that many instances of the resource, each with its own separate state entry:

resource "aws_instance" "web" {
  count         = 3
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  tags = {
    Name = "web-${count.index}"
  }
}

This creates three EC2 instances: aws_instance.web[0], aws_instance.web[1], and aws_instance.web[2]. Inside the block, count.index is the zero-based index of the current instance.

You can reference individual instances or all of them:

# Reference a specific instance
output "first_ip" {
  value = aws_instance.web[0].private_ip
}

# Collect all private IPs into a list
output "all_ips" {
  value = aws_instance.web[*].private_ip
}

count is commonly driven by a variable to make the number configurable:

variable "instance_count" {
  type    = number
  default = 2
}

resource "aws_instance" "app" {
  count         = var.instance_count
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.small"
}
⚠️
Removing from the middle destroys everything after it

If you have count = 3 and remove the second item from a list used to set names, Terraform destroys [1] and [2] and recreates [1] with the new values. This is why for_each is safer for named resources.

Conditional resources with count

Setting count = 0 effectively removes a resource. This is the idiomatic way to make a resource optional:

variable "create_bastion" {
  type    = bool
  default = false
}

resource "aws_instance" "bastion" {
  count         = var.create_bastion ? 1 : 0
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}

Reference it with care — if count = 0, aws_instance.bastion is an empty list, so use one(aws_instance.bastion) or aws_instance.bastion[0] only when count is guaranteed non-zero.

for_each

for_each iterates over a map or set of strings, creating one resource instance per element. Each instance is keyed by the map key or set value — stable keys mean stable state entries:

variable "buckets" {
  type = map(string)
  default = {
    logs    = "us-east-1"
    backups = "us-west-2"
    assets  = "eu-west-1"
  }
}

resource "aws_s3_bucket" "store" {
  for_each = var.buckets
  bucket   = "myapp-${each.key}"

  tags = {
    Region = each.value
  }
}

Inside the block, each.key is the map key and each.value is the map value. This creates three buckets with stable addresses: aws_s3_bucket.store["logs"], aws_s3_bucket.store["backups"], aws_s3_bucket.store["assets"].

Iterating over a set of strings:

variable "availability_zones" {
  type    = set(string)
  default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

resource "aws_subnet" "private" {
  for_each          = var.availability_zones
  vpc_id            = aws_vpc.main.id
  availability_zone = each.key   # for sets, each.key == each.value
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 4, index(tolist(var.availability_zones), each.key))
}

Iterating over a list of objects requires converting to a map first, since Terraform needs stable string keys:

variable "users" {
  type = list(object({
    name  = string
    email = string
    role  = string
  }))
}

resource "aws_iam_user" "team" {
  for_each = { for u in var.users : u.name => u }
  name     = each.key
  tags = {
    Email = each.value.email
    Role  = each.value.role
  }
}

The { for u in var.users : u.name => u } expression is a for expression that converts the list to a map keyed by name. This pattern is extremely common in real configurations.

count vs for_each

countfor_each
Key typeInteger index (0, 1, 2…)String key from map/set
Best forIdentical copies, conditional resourcesNamed resources, objects with distinct config
Reorder safetyDangerous — index shift destroys downstreamSafe — keys are stable strings
Reference syntaxresource.name[0]resource.name["key"]
All instancesresource.name[*].attrvalues(resource.name)[*].attr

Rule of thumb: use count only for truly identical resources or conditional creates. Use for_each for anything with distinct names, configurations, or where the set might change.

depends_on

Terraform automatically infers ordering from references — if resource A references an attribute of resource B, Terraform knows to create B first. But sometimes there's a dependency that isn't expressed through attribute references: for example, an application depends on an IAM policy being fully propagated before it can call AWS APIs.

depends_on lets you encode these hidden dependencies explicitly:

resource "aws_iam_role_policy_attachment" "app" {
  role       = aws_iam_role.app.name
  policy_arn = aws_iam_policy.app.arn
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.small"
  iam_instance_profile = aws_iam_instance_profile.app.name

  # The instance's startup script calls AWS APIs.
  # IAM propagation takes time — wait for the policy to be attached first.
  depends_on = [aws_iam_role_policy_attachment.app]
}

depends_on accepts a list of resource or module references. It forces Terraform to fully create/update/destroy the listed resources before touching the block that declares it.

🧭
Use depends_on sparingly

depends_on is a blunt instrument — it serializes operations that Terraform could otherwise parallelize. It also makes the dependency invisible to readers who don't check it. Prefer implicit dependencies (reference the resource's attributes) wherever possible. Reserve depends_on for genuinely non-obvious ordering requirements like IAM propagation delays or external provisioning scripts.

depends_on also works on modules:

module "app" {
  source = "./modules/app"

  depends_on = [module.network]
}

lifecycle

The lifecycle block controls how Terraform handles create, update, and destroy operations for a specific resource. It has four arguments:

create_before_destroy

By default, Terraform destroys the old resource before creating the replacement. For resources where downtime is unacceptable, reverse this:

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.small"

  lifecycle {
    create_before_destroy = true
  }
}

Terraform creates the new instance first, then destroys the old one. Useful for load-balanced instances and database replicas.

prevent_destroy

Prevents Terraform from destroying the resource — any plan that would destroy it fails with an error:

resource "aws_rds_cluster" "primary" {
  cluster_identifier = "prod-db"
  engine             = "aurora-postgresql"

  lifecycle {
    prevent_destroy = true
  }
}

ignore_changes

Tells Terraform to ignore drift in specific attributes — useful for attributes managed outside Terraform at runtime:

resource "aws_autoscaling_group" "app" {
  min_size = 2
  max_size = 10

  lifecycle {
    ignore_changes = [desired_capacity, tag]
  }
}

replace_triggered_by

Forces resource replacement when another resource or attribute changes — even if the resource itself hasn't changed:

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.small"

  lifecycle {
    # Replace the instance whenever the launch template changes
    replace_triggered_by = [aws_launch_template.app.id]
  }
}

provider

When you have multiple configurations of the same provider (e.g., multiple AWS regions), use provider to select which one applies:

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

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

resource "aws_s3_bucket" "east" {
  bucket = "myapp-east"
  # uses the default provider (us-east-1)
}

resource "aws_s3_bucket" "west" {
  bucket   = "myapp-west"
  provider = aws.west   # use the aliased provider
}

Key Takeaways