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-argument | Accepts | Purpose |
|---|---|---|
depends_on | list of addresses | dependency a reference cannot express |
count | number | N near-identical instances, indexed [0], [1] |
for_each | map or set of strings | instances keyed by string, ["key"] |
provider | provider reference | pick an aliased provider configuration |
lifecycle | nested block | control 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_ontakes static addresses only, never expressions orvar.*.- A
depends_onon amoduleblock 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
countis0the address is an empty tuple, not null.aws_nat_gateway.main[0]errors. Useone(...),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
count | for_each | |
|---|---|---|
| Identity | positional index | string key |
| Iterator | count.index (number) | each.key, each.value |
| Stable under reordering | no | yes |
| Works on a list of objects | yes | no, key it first |
| Right for | truly interchangeable copies, on/off toggles | anything 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." } } }
| Argument | Effect | Gotcha |
|---|---|---|
create_before_destroy | replacement created before old destroyed | needs unique names, and it propagates to dependencies |
prevent_destroy | any plan that destroys this errors | blocks terraform destroy entirely, remove it to tear down |
destroy = false | object is never destroyed (1.16+) | plan proceeds, the object is just left alone |
ignore_changes | drop drift on listed attributes | static attribute names only, no expressions |
replace_triggered_by | force replacement when a referenced value changes | references only, resources or their attributes |
precondition | checked before create or update, fails the plan | evaluate inputs and data sources here |
postcondition | checked after apply, self available, fails the run | guards 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.keyandingress.value. Rename it withiterator = rulewhen nesting two dynamics. - Over a list,
.keyis the index. Over a map or set,.keyis the key. dynamiccannot generate meta-argument blocks such aslifecycleorprovisioner.- Two literal blocks read better than one
dynamic. Use it only when the count is genuinely variable.