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.id is invalid. If a caller needs a value, the module must declare an output for it.
  • Child modules must not declare a backend or cloud block. State belongs to the root.
  • Every module block needs source. Changing source or version requires terraform init again.
  • Module calls are addressed module.NAME, and with count/for_each, module.NAME[0] or module.NAME["key"].

Module Sources

SourceExampleversion 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

version only 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 every terraform init -upgrade can 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.1 is 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).

ArgumentNotes
sourcerequired, no expressions allowed, must be a literal string
versionregistry and OCI sources only
countmodule.name[0], the ? 1 : 0 toggle idiom
for_eachmodule.name["key"], each.key / each.value inside the block
depends_onapplies to every resource in the module and everything it calls
providersmaps 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 one aws_s3_bucket is 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 environment variable that branches on it.
RelatedWhere
count, for_each, depends_on in depthMeta-Arguments
Moving a resource into a module without destroying itImport and Refactoring
Testing a module with .tftest.hclTesting
Backends and per-environment stateState