Skip to content
Infrastructure as Code iac hcl 4 min read

Meta-Arguments Overview

Most arguments inside a resource or module block are defined by the provider — ami, instance_type, cidr, and so on. Meta-arguments are different: they are built into Terraform’s language itself and behave the same regardless of which provider or resource type you use. They control how many instances of a block to create, what it depends on, which provider configuration it uses, and how Terraform treats its lifecycle. Mastering these five arguments unlocks most of the patterns you will reach for in real configurations, and they work identically in OpenTofu.

The five meta-arguments at a glance

Every meta-argument solves a distinct problem. The table below summarizes what each one does and where it applies.

Meta-argumentAvailable onPurpose
countresource, data, moduleCreate a fixed number of identical instances, indexed 0..n-1
for_eachresource, data, moduleCreate one instance per key in a map or set, indexed by key
depends_onresource, data, moduleDeclare a hidden dependency Terraform cannot infer
providerresource, data, moduleSelect a non-default (aliased) provider configuration
lifecycleresourceTune create/update/destroy behavior

You may use either count or for_each on a given block, but never both at once.

count

count repeats a block a fixed number of times. Each instance is addressed by an index through count.index, and the whole set is referenced as a list.

resource "aws_instance" "worker" {
  count         = 3
  ami           = "ami-0c2b8ca1dc6cf8160"
  instance_type = "t3.micro"

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

This produces aws_instance.worker[0], [1], and [2]. count is ideal for genuinely identical, order-insensitive instances. Its weakness: because instances are tracked by position, removing the middle element shifts every later index and forces Terraform to destroy and recreate resources. See conditionals for the common count = var.enabled ? 1 : 0 toggle pattern.

for_each

for_each creates one instance per element of a map or a set of strings, addressing each by a stable key instead of a numeric position. This avoids the index-shifting problem entirely.

resource "aws_iam_user" "team" {
  for_each = toset(["alice", "bob", "carol"])
  name     = each.value
}

resource "aws_s3_bucket" "logs" {
  for_each = {
    app = "myapp-app-logs"
    web = "myapp-web-logs"
  }
  bucket = each.value

  tags = { Tier = each.key }
}

Inside the block, each.key and each.value expose the current element. Resources become aws_iam_user.team["alice"] and so on. Because keys are stable, adding or removing one entry only affects that entry. Use for expressions to transform a list into the map for_each expects.

Prefer for_each over count whenever the instances are distinguishable. Switching an existing count resource to for_each later requires terraform state mv for every instance, so choosing well up front saves real pain.

depends_on

Terraform builds its dependency graph automatically from references between resources. depends_on covers the rare cases where a dependency is real but invisible — for example, an IAM policy that must exist before an application can assume a role, even though no attribute links them.

resource "aws_iam_role_policy" "app" {
  name   = "app-policy"
  role   = aws_iam_role.app.id
  policy = data.aws_iam_policy_document.app.json
}

resource "aws_instance" "app" {
  ami           = "ami-0c2b8ca1dc6cf8160"
  instance_type = "t3.small"

  depends_on = [aws_iam_role_policy.app]
}

Use depends_on sparingly; an explicit reference (role = aws_iam_role.app.id) is always preferable because it documents the relationship and is harder to get wrong.

provider

When you define aliased provider configurations, the provider meta-argument points a resource at a specific one. The value is the provider name plus alias, written without quotes.

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

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

resource "aws_s3_bucket" "replica" {
  provider = aws.west
  bucket   = "myapp-replica-us-west-2"
}

Without this argument, a resource uses the default (unaliased) configuration. This is how you provision across multiple regions or accounts in one configuration.

lifecycle

The lifecycle nested block changes how Terraform plans changes to a resource. Its most-used arguments are listed below.

ArgumentEffect
create_before_destroyProvision the replacement before destroying the old instance
prevent_destroyAbort any plan that would destroy this resource
ignore_changesSkip drift on specified attributes managed outside Terraform
replace_triggered_byForce replacement when a referenced value changes
resource "aws_db_instance" "main" {
  identifier        = "myapp-prod"
  engine            = "postgres"
  instance_class    = "db.t3.medium"
  allocated_storage = 20

  lifecycle {
    prevent_destroy = true
    ignore_changes  = [allocated_storage]
  }
}

A terraform plan that would delete this database stops with an error instead.

Output:

Error: Instance cannot be destroyed

  on main.tf line 1:
   1: resource "aws_db_instance" "main" {

Resource aws_db_instance.main has lifecycle.prevent_destroy set, but the plan
calls for this resource to be destroyed.

Best Practices

  • Reach for for_each by default and fall back to count only for truly anonymous, fixed-size sets.
  • Never combine count and for_each on the same block — Terraform rejects it at parse time.
  • Keep depends_on as a last resort; model dependencies through attribute references whenever possible.
  • Add create_before_destroy = true to resources fronted by load balancers or DNS to avoid downtime during replacement.
  • Use ignore_changes for attributes mutated by autoscaling or external automation, but list specific attributes rather than all.
  • Guard stateful resources (databases, buckets with data) with prevent_destroy to stop accidental deletion.
Last updated June 14, 2026
Was this helpful?