Terraform Modules Deep Dive Cheat Sheet
Detailed reference for structuring, calling, and versioning reusable Terraform modules including inputs, outputs, and remote sources.
Standard Module Layout
Conventional file layout for a reusable Terraform module.
modules/vpc/├── main.tf # Resource definitions├── variables.tf # Input variable declarations├── outputs.tf # Output value declarations├── versions.tf # required_providers / required_version└── README.md # Usage docs
Variables & Outputs
Declaring typed inputs and exposing outputs from a module.
# variables.tfvariable "cidr_block" { type = string description = "CIDR range for the VPC" default = "10.0.0.0/16"}variable "subnet_count" { type = number default = 2}# outputs.tfoutput "vpc_id" { value = aws_vpc.this.id description = "ID of the created VPC"}
Calling a Module
Referencing local and remote (registry/Git) module sources.
module "vpc" { source = "./modules/vpc" # Local path cidr_block = "10.1.0.0/16" subnet_count = 3}module "vpc_registry" { source = "terraform-aws-modules/vpc/aws" # Registry version = "~> 5.0" cidr = "10.2.0.0/16"}module "vpc_git" { source = "git::https://github.com/org/tf-modules.git//vpc?ref=v1.4.0"}# Reference an output from another moduleresource "aws_instance" "app" { subnet_id = module.vpc.public_subnet_ids[0]}
Module Composition Features
Meta-arguments and patterns for flexible, reusable modules.
- count / for_each on module blocks- Instantiate a module multiple times, e.g. for_each = toset(var.environments)
- depends_on (module)- Force explicit ordering when dependencies aren't inferable from references
- providers = { aws = aws.west }- Pass specific provider configurations/aliases into a child module
- terraform_remote_state- Data source to read outputs from another Terraform state/module
- locals block- Compute derived values once and reuse them across a module for DRY expressions
- moved block- Declares a resource/module was renamed or relocated, avoiding destroy/recreate on refactors
Module Versioning & Sources
Pinning module versions for reproducible infrastructure.
- version = "~> 5.0"- Registry modules support version constraints, resolved via terraform init
- ?ref=v1.4.0- Git sources pin to a tag, branch, or commit SHA
- terraform init -upgrade- Re-resolve modules/providers to the latest versions matching constraints
- .terraform.lock.hcl- Lock file recording exact provider versions and checksums (not module versions)
Dynamic Blocks & Conditional Resources
Generating repeated nested blocks and toggling resource creation from module inputs.
variable "ingress_rules" { type = list(object({ port = number proto = string cidr = string })) default = []}resource "aws_security_group" "this" { name = "app-sg" dynamic "ingress" { for_each = var.ingress_rules content { from_port = ingress.value.port to_port = ingress.value.port protocol = ingress.value.proto cidr_blocks = [ingress.value.cidr] } }}# Conditional resource creation via countresource "aws_instance" "bastion" { count = var.enable_bastion ? 1 : 0 ami = var.bastion_ami instance_type = "t3.micro"}# Safe reference when count may be 0output "bastion_ip" { value = var.enable_bastion ? aws_instance.bastion[0].public_ip : null}
Input Validation & Custom Conditions
Guarding module inputs and outputs with validation blocks, preconditions, and postconditions.
variable "environment" { type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be one of: dev, staging, prod." }}resource "aws_instance" "app" { ami = var.ami_id instance_type = var.instance_type lifecycle { precondition { condition = data.aws_ami.selected.architecture == "x86_64" error_message = "Selected AMI must be x86_64." } postcondition { condition = self.public_ip != "" error_message = "Instance did not receive a public IP." } }}
Testing Modules (terraform test)
Native HCL test framework (Terraform 1.6+) for asserting module behavior in isolation.
# tests/vpc.tftest.hclvariables { cidr_block = "10.0.0.0/16" subnet_count = 2}run "creates_expected_subnet_count" { command = plan assert { condition = length(aws_subnet.public) == var.subnet_count error_message = "Expected ${var.subnet_count} subnets to be planned" }}run "apply_and_check_vpc_id" { command = apply assert { condition = output.vpc_id != "" error_message = "vpc_id output should not be empty" }}# CLI: terraform test
State Manipulation for Module Refactors
Commands and blocks for safely restructuring modules without destroying real infrastructure.
- terraform state mv- Renames a resource address in state, e.g. moving a resource into a newly extracted module without recreating it
- moved block (declarative)- Codified, reviewable alternative to state mv; committed to the module so history persists across environments
- import block (Terraform 1.5+)- Declaratively imports existing infrastructure into state via a plan, replacing the imperative terraform import command
- terraform state rm- Removes a resource from state without destroying it, useful before deleting a module block you want to hand-manage
- removed block- Declares a resource should be dropped from state (not destroyed) when a module is deleted, avoiding accidental deletion
- terraform state show- Prints the current attributes Terraform has recorded for a specific resource, useful for debugging drift
Multi-Provider Modules
Requiring and wiring multiple aliased provider configurations through a module boundary.
# modules/multi-region/versions.tfterraform { required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0" configuration_aliases = [aws.primary, aws.secondary] } }}# root moduleprovider "aws" { alias = "primary" region = "us-east-1"}provider "aws" { alias = "secondary" region = "eu-west-1"}module "multi_region" { source = "./modules/multi-region" providers = { aws.primary = aws.primary aws.secondary = aws.secondary }}
Pin remote module sources to an immutable ref (a Git tag or commit SHA, or a registry version constraint) rather than a branch like `main` — otherwise a plan can silently pick up unreviewed upstream changes.