Terraform Cheatsheet
Modules
Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
What Modules Are
A module is any directory containing .tf files. The directory you run terraform in is the root module. Anything it calls with a module block is a child module. There is no other kind, so a module is just a directory you decided to reuse.
module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" # the module's input variables environment = var.environment az_count = 3 } output "vpc_id" { value = module.vpc.vpc_id # only declared outputs are readable }
Rules that bite:
- A child module's resources are not addressable from outside.
module.vpc.aws_vpc.main.idis invalid. If a caller needs a value, the module must declare anoutputfor it. - Child modules must not declare a
backendorcloudblock. State belongs to the root. - Every
moduleblock needssource. Changingsourceorversionrequiresterraform initagain. - Module calls are addressed
module.NAME, and withcount/for_each,module.NAME[0]ormodule.NAME["key"].
Module Sources
| Source | Example | version arg |
|---|---|---|
| Local path | "./modules/vpc" (must start ./ or ../) | no |
| Registry | "terraform-aws-modules/vpc/aws" | yes |
| Private registry | "app.terraform.io/acme/vpc/aws" | yes |
| Generic git | "git::https://example.com/vpc.git//sub?ref=v1.2.0" | no |
| Git over SSH | "git::ssh://git@github.com/org/repo.git?ref=v1.0.0" | no |
| GitHub shorthand | "github.com/org/repo//modules/vpc?ref=v1.0.0" | no |
| Bitbucket | "bitbucket.org/org/repo//modules/vpc" | no |
| S3 | "s3::https://bucket.s3.us-east-1.amazonaws.com/vpc.zip" | no |
| GCS | "gcs::https://www.googleapis.com/storage/v1/b/mods/o/vpc.zip" | no |
| HTTP archive | "https://example.com/vpc.zip" | no |
| OCI registry (1.14+) | "oci://example.registry.io/acme/vpc" | yes |
versiononly works for registry and OCI sources. For git, GitHub, S3 and HTTP you pin inside the source string itself. Silently omitting a pin on a git module means everyterraform init -upgradecan pull a different commit.
# Registry: pin with a pessimistic constraint module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" # >= 5.0.0, < 6.0.0 name = "acme-prod" cidr = "10.0.0.0/16" azs = ["us-east-1a", "us-east-1b", "us-east-1c"] } # Git: pin with ?ref= to a TAG or a commit SHA, never a branch module "app" { source = "git::https://github.com/acme/tf-modules.git//app?ref=v2.4.1" }
The // in a source separates the repository from a subdirectory inside it. ?ref= accepts a tag, a branch or a full commit SHA. Use tags or SHAs, because a branch ref means your module changes under you.
Standard Module Structure
The layout the registry documents and every reader expects.
modules/vpc/
├── main.tf # the resources
├── variables.tf # inputs, each with a type and description
├── outputs.tf # everything a caller may need
├── versions.tf # required_version + required_providers
├── README.md # what it makes, and a usage block
├── LICENSE
├── examples/ # runnable root modules that call this one
│ └── complete/
└── tests/ # .tftest.hcl files# modules/vpc/versions.tf terraform { required_version = ">= 1.5" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0" # a RANGE in a module, not ~> or = } } }
Modules declare permissive provider constraints (
>= 5.0). Root modules declare strict ones (~> 5.83). A module that pins= 5.83.1is unusable by anyone whose root pins anything else, because provider versions resolve once across the whole configuration.
Module Meta-Arguments
source and version are module-only. The rest mirror the resource meta-arguments (see the Meta-Arguments topic).
| Argument | Notes |
|---|---|
source | required, no expressions allowed, must be a literal string |
version | registry and OCI sources only |
count | module.name[0], the ? 1 : 0 toggle idiom |
for_each | module.name["key"], each.key / each.value inside the block |
depends_on | applies to every resource in the module and everything it calls |
providers | maps the caller's providers onto the module's expectations |
module "app" { source = "./modules/app" count = var.enable_app ? 1 : 0 # conditional module } module "region" { source = "./modules/region" for_each = toset(["us-east-1", "us-west-2"]) region = each.key }
source cannot be interpolated. source = "./modules/${var.kind}" is an error, because Terraform resolves modules before it evaluates variables.
Passing Providers Explicitly
A module inherits the caller's default provider configurations automatically. Aliased configurations are never inherited, so they must be mapped.
provider "aws" { region = "us-east-1" } provider "aws" { alias = "west" region = "us-west-2" } module "west_resources" { source = "./modules/app" providers = { aws = aws.west # module's default aws becomes our west } }
For a module that genuinely needs two regions at once, the module declares its own alias names in configuration_aliases and the caller fills them in.
# modules/replicated/versions.tf terraform { required_providers { aws = { source = "hashicorp/aws" configuration_aliases = [aws.primary, aws.replica] } } }
module "replicated" { source = "./modules/replicated" providers = { aws.primary = aws aws.replica = aws.west } }
Never put a provider block inside a reusable module. It makes the module impossible to instantiate twice and blocks removal of the module from the config.
Composition and Module Outputs
Wire modules together through outputs and inputs, not through shared globals.
module "network" { source = "./modules/network" cidr = "10.0.0.0/16" } module "cluster" { source = "./modules/cluster" vpc_id = module.network.vpc_id subnet_ids = module.network.private_subnet_ids }
# Collecting outputs from a for_each'd module: build a map, not a list module "bucket" { source = "./modules/s3-bucket" for_each = toset(["logs", "backups"]) name = each.key } output "bucket_arns" { value = { for k, m in module.bucket : k => m.bucket_arn } }
terraform init # downloads modules and providers terraform get # modules only terraform get -update # re-resolve module sources terraform init -upgrade # re-resolve modules AND providers
Local path modules are read from disk on every run, so editing one needs no re-init. Registry and git modules are cached in .terraform/modules/, so a moved tag needs -update.
Do Not Over-Abstract
The most common Terraform failure is not a missing module, it is a module that wraps one resource and exposes a variable for every argument.
- Do not write a module for a single resource.
module "bucket"that passes ten variables into oneaws_s3_bucketis worse than the resource, because it adds a version to manage and hides the provider docs. - Write a module when it composes several resources into one meaningful unit, and when you will call it more than twice.
- Keep nesting shallow. Two levels deep is usually a sign, three levels means nobody can trace where a value came from.
- Do not add a variable "just in case". Every input is API surface you cannot remove without a major version.
- A wrapper module around a public registry module usually means you should have used the registry module directly with a locals block for your defaults.
- Prefer flat root modules per environment over one clever module with an
environmentvariable that branches on it.
| Related | Where |
|---|---|
count, for_each, depends_on in depth | Meta-Arguments |
| Moving a resource into a module without destroying it | Import and Refactoring |
Testing a module with .tftest.hcl | Testing |
| Backends and per-environment state | State |