Terraform Cheatsheet

Meta-Arguments and Lifecycle

Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

The Five Resource Meta-Arguments

Meta-arguments are built into Terraform itself, not into the provider schema, so every resource and data block accepts them regardless of type.

Meta-argumentAcceptsPurpose
depends_onlist of addressesdependency a reference cannot express
countnumberN near-identical instances, indexed [0], [1]
for_eachmap or set of stringsinstances keyed by string, ["key"]
providerprovider referencepick an aliased provider configuration
lifecyclenested blockcontrol create, update, replace, destroy

count and for_each are mutually exclusive on the same block. Both also work on module, import, check, list and action blocks.

depends_on

Terraform builds its graph from attribute references, so subnet_id = aws_subnet.public.id is already a dependency. You only need depends_on when the ordering requirement is invisible to Terraform.

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = "t3.micro"

  # The instance profile must exist before the app boots and calls
  # AWS, but nothing in this block references the attachment.
  depends_on = [aws_iam_role_policy_attachment.app]
}

Rules that bite:

  • depends_on takes static addresses only, never expressions or var.*.
  • A depends_on on a module block waits for every resource inside it.
  • Over-using it serializes your graph and slows applies. Reach for a reference first.

count

resource "aws_instance" "worker" {
  count         = var.worker_count       # 0 is legal, creates nothing
  ami           = var.ami_id
  instance_type = "t3.small"
  tags          = { Name = "worker-${count.index}" }
}

# Addresses: aws_instance.worker[0], aws_instance.worker[1], ...
# Whole list:  aws_instance.worker            (a tuple)
# Splat:       aws_instance.worker[*].private_ip

The conditional-resource idiom, and the indexing it forces on every consumer:

resource "aws_nat_gateway" "main" {
  count         = var.enable_nat ? 1 : 0
  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id
}

# Consumers must index, and must survive count = 0:
output "nat_ip" {
  value = one(aws_nat_gateway.main[*].public_ip)   # null when absent
}

When count is 0 the address is an empty tuple, not null. aws_nat_gateway.main[0] errors. Use one(...), try(...) or a splat.

for_each and Why It Beats count

# Over a set of strings: each.key == each.value
resource "aws_iam_user" "team" {
  for_each = toset(["alice", "bob", "carol"])
  name     = each.key
}

# Over a map of objects: each.key is the key, each.value the object
variable "buckets" {
  type = map(object({ versioned = bool }))
  default = {
    logs    = { versioned = true }
    backups = { versioned = false }
  }
}

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

# Addresses: aws_s3_bucket.app["logs"], aws_s3_bucket.app["backups"]

The reindexing problem. With count, identity is position. Remove the middle element of ["a", "b", "c"] and index 1 changes from b to c, so Terraform plans to update or replace [1] and destroy [2]. Two resources churn because one was deleted.

count over ["a","b","c"]        remove "b"
  [0] = a                        [0] = a      no change
  [1] = b                        [1] = c      REPLACED
  [2] = c                        —            DESTROYED

for_each over toset(["a","b","c"])   remove "b"
  ["a"]                          ["a"]        no change
  ["b"]                          —            DESTROYED
  ["c"]                          ["c"]        no change
countfor_each
Identitypositional indexstring key
Iteratorcount.index (number)each.key, each.value
Stable under reorderingnoyes
Works on a list of objectsyesno, key it first
Right fortruly interchangeable copies, on/off togglesanything named or config-distinct

Keys must be known at plan time. If a key is derived from an unknown attribute Terraform errors with "Invalid for_each argument". Key by something you already have, a variable or a literal, not by a generated ID.

# Turn a list of objects into a keyed map first
locals {
  subnets = { for s in var.subnets : s.name => s }
}

resource "aws_subnet" "this" {
  for_each   = local.subnets
  cidr_block = each.value.cidr
  tags       = { Name = each.key }
}

provider and alias

provider "aws" {
  region = "us-east-1"          # default configuration
}

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

resource "aws_s3_bucket" "replica" {
  provider = aws.west            # <local_name>.<alias>, no quotes
  bucket   = "myapp-replica"
}

An aliased provider is never inherited by a child module. Pass it explicitly with the module providers map, covered below.

lifecycle

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"

  lifecycle {
    create_before_destroy = true      # build the new one first
    prevent_destroy       = true      # destroy plans error out
    destroy               = false     # 1.16+, never destroy this object

    ignore_changes = [
      tags["LastDeployed"],           # one map key
      user_data,                      # or a whole attribute
    ]
    # ignore_changes = all            # ignore every drift after create

    replace_triggered_by = [
      aws_launch_template.app.latest_version,
    ]

    precondition {
      condition     = data.aws_ami.this.architecture == "x86_64"
      error_message = "AMI must be x86_64 for this instance family."
    }

    postcondition {
      condition     = self.public_ip != ""
      error_message = "Instance did not receive a public IP."
    }
  }
}
ArgumentEffectGotcha
create_before_destroyreplacement created before old destroyedneeds unique names, and it propagates to dependencies
prevent_destroyany plan that destroys this errorsblocks terraform destroy entirely, remove it to tear down
destroy = falseobject is never destroyed (1.16+)plan proceeds, the object is just left alone
ignore_changesdrop drift on listed attributesstatic attribute names only, no expressions
replace_triggered_byforce replacement when a referenced value changesreferences only, resources or their attributes
preconditionchecked before create or update, fails the planevaluate inputs and data sources here
postconditionchecked after apply, self available, fails the runguards what you produced

ignore_changes = all is a blunt instrument. It hides every future config change on that resource, so most teams regret it.

action_trigger (1.14+)

action blocks are provider-defined side effects. A resource invokes one from inside lifecycle.

resource "aws_lambda_function" "api" {
  # ...
  lifecycle {
    action_trigger {
      events     = [after_create, after_update]
      actions    = [action.aws_lambda_invoke.warm]
      on_failure = halt          # halt (default) | continue | taint
    }
  }
}

Events are before_create, after_create, before_update, after_update, plus before_destroy and after_destroy (1.16+). on_failure arrived in 1.16. See the Resources topic for the action block itself.

Module Meta-Arguments

module "app" {
  source  = "terraform-aws-modules/ecs/aws"
  version = "~> 6.0"                  # registry and HCP sources only

  count      = var.enable_app ? 1 : 0
  depends_on = [module.vpc]

  providers = {
    aws           = aws.west          # module's default aws provider
    aws.secondary = aws.east          # module's own aliased provider
  }

  cluster_name = "myapp"
}

source and version are module-only. They must be literal strings unless the value comes from a const = true variable or a local (1.15+). Everything else in the list behaves as it does on a resource, and for_each over modules gives module.app["key"].output_name.

dynamic Blocks

for_each on a resource makes more resources. dynamic makes more nested blocks inside one resource.

variable "rules" {
  type = list(object({
    port  = number
    cidrs = list(string)
  }))
}

resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidrs
    }
  }
}
  • The iterator is named after the block label, so ingress.key and ingress.value. Rename it with iterator = rule when nesting two dynamics.
  • Over a list, .key is the index. Over a map or set, .key is the key.
  • dynamic cannot generate meta-argument blocks such as lifecycle or provisioner.
  • Two literal blocks read better than one dynamic. Use it only when the count is genuinely variable.