Terraform Modules
Terraform module design, composition, versioning, and reuse patterns
You are an expert in Terraform module design and composition for infrastructure as code. ## Key Points - **Keep modules focused.** A module should manage one logical component (a VPC, a database, a service). Avoid "god modules" that create entire environments. - **Expose only necessary variables.** Do not expose every possible attribute as a variable. Choose sensible defaults and let callers override what they need. - **Always set `description` on variables and outputs.** This is the primary documentation for module consumers. - **Use `validation` blocks** on variables to catch invalid input early, before the plan phase. - **Version your modules.** Use Git tags or a module registry. Never point at `main` branch in production. - **Write examples.** Include an `examples/` directory showing typical usage patterns. - **Avoid hard-coding provider configuration in modules.** Let the caller configure providers. Modules should inherit the provider from the calling module. - **Use `terraform-docs`** to auto-generate documentation from variable and output blocks. - **Nesting modules too deeply.** Three or more levels of module nesting makes debugging state paths painful. Keep the hierarchy shallow. - **Passing providers into modules incorrectly.** If a module needs a non-default provider (e.g., a different AWS region), use the `providers` meta-argument explicitly. - **Changing module source without running `terraform init -upgrade`.** Terraform caches module sources. After changing a version or source URL, always re-initialize. - **Outputting sensitive values without marking them.** If a module output contains a secret, set `sensitive = true` on the output to prevent it from appearing in CLI output.
skilldb get terraform-skills/Terraform ModulesFull skill: 289 linesModules — Terraform
You are an expert in Terraform module design and composition for infrastructure as code.
Overview
Modules are the primary mechanism for code reuse in Terraform. A module is any directory containing .tf files. The root module is the working directory where you run terraform apply. Child modules are called from the root module or from other modules. Well-designed modules encapsulate a logical set of resources behind a clean interface of variables and outputs.
Core Concepts
Module Structure
A well-organized module follows a standard file layout:
modules/
vpc/
main.tf # Resource definitions
variables.tf # Input variables
outputs.tf # Output values
versions.tf # Required providers and Terraform version
README.md # Usage documentation
examples/
simple/
main.tf
complete/
main.tf
Calling a Module
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
environment = "production"
project = "myapp"
}
# Access module outputs
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_ids[0]
}
Module Sources
# Local path
module "vpc" {
source = "./modules/vpc"
}
# Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
# Git repository with tag
module "vpc" {
source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v1.2.0"
}
# S3 bucket
module "vpc" {
source = "s3::https://s3-eu-west-1.amazonaws.com/my-modules/vpc.zip"
}
Implementation Patterns
Composable Module Design
Design modules that do one thing well and compose them together.
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project}-${var.environment}"
}
}
resource "aws_subnet" "public" {
for_each = { for idx, az in var.availability_zones : az => idx }
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, each.value)
availability_zone = each.key
map_public_ip_on_launch = true
tags = {
Name = "${var.project}-public-${each.key}"
Tier = "public"
}
}
resource "aws_subnet" "private" {
for_each = { for idx, az in var.availability_zones : az => idx + length(var.availability_zones) }
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, each.value)
availability_zone = each.key
tags = {
Name = "${var.project}-private-${each.key}"
Tier = "private"
}
}
# modules/vpc/variables.tf
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
}
variable "project" {
description = "Project name used for tagging"
type = string
}
variable "environment" {
description = "Environment name (e.g., production, staging)"
type = string
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = [for s in aws_subnet.public : s.id]
}
output "private_subnet_ids" {
description = "IDs of private subnets"
value = [for s in aws_subnet.private : s.id]
}
Module Composition in Root
# root main.tf — compose modules together
module "vpc" {
source = "./modules/vpc"
cidr_block = var.vpc_cidr
availability_zones = var.azs
project = var.project
environment = var.environment
}
module "ecs_cluster" {
source = "./modules/ecs-cluster"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
project = var.project
environment = var.environment
}
module "rds" {
source = "./modules/rds"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
allowed_cidr = [var.vpc_cidr]
project = var.project
environment = var.environment
}
Conditional Resource Creation
variable "create_nat_gateway" {
description = "Whether to create a NAT gateway"
type = bool
default = true
}
resource "aws_nat_gateway" "this" {
count = var.create_nat_gateway ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = values(aws_subnet.public)[0].id
}
output "nat_gateway_id" {
description = "ID of the NAT gateway, if created"
value = try(aws_nat_gateway.this[0].id, null)
}
Module Wrappers for Opinionated Defaults
# modules/web-app/main.tf — opinionated wrapper combining lower-level modules
module "alb" {
source = "terraform-aws-modules/alb/aws"
version = "~> 9.0"
name = "${var.project}-alb"
load_balancer_type = "application"
vpc_id = var.vpc_id
subnets = var.public_subnet_ids
security_groups = [aws_security_group.alb.id]
# Opinionated: always enable access logs
access_logs = {
bucket = var.log_bucket
prefix = "alb/${var.project}"
}
}
Best Practices
- Keep modules focused. A module should manage one logical component (a VPC, a database, a service). Avoid "god modules" that create entire environments.
- Expose only necessary variables. Do not expose every possible attribute as a variable. Choose sensible defaults and let callers override what they need.
- Always set
descriptionon variables and outputs. This is the primary documentation for module consumers. - Use
validationblocks on variables to catch invalid input early, before the plan phase. - Version your modules. Use Git tags or a module registry. Never point at
mainbranch in production. - Write examples. Include an
examples/directory showing typical usage patterns. - Avoid hard-coding provider configuration in modules. Let the caller configure providers. Modules should inherit the provider from the calling module.
- Use
terraform-docsto auto-generate documentation from variable and output blocks.
Core Philosophy
Modules exist to create reusable, testable abstractions over raw infrastructure resources. A well-designed module hides complexity behind a simple interface of variables and outputs, letting consumers provision infrastructure without understanding every underlying resource. The goal is not to wrap every resource in a module, but to encapsulate logical components that are used more than once or that benefit from a curated set of defaults.
Good module design follows the principle of minimal surface area. Expose only the variables that consumers genuinely need to customize, and provide sensible defaults for everything else. A module with 50 input variables is not reusable; it is a configuration dump that shifts complexity to the caller. The best modules make the common case trivial and the advanced case possible through a small number of well-chosen escape hatches.
Versioning and backward compatibility are what separate a module from a copy-pasted directory of Terraform files. When you publish a module with a tagged version, consumers can pin to it and upgrade deliberately. Breaking changes to a module's interface (renaming a variable, changing a type, removing an output) should be treated with the same care as breaking changes to a public API, because the blast radius is every configuration that calls that module.
Anti-Patterns
-
God modules that create entire environments. A single module that provisions a VPC, ECS cluster, RDS database, and S3 buckets is impossible to test in isolation, painful to debug, and forces consumers to accept all-or-nothing. Break large modules into composable, single-purpose components.
-
Pointing module sources at
mainbranch. Usingsource = "git::https://...?ref=main"in production means any push to the module repository can silently change your infrastructure on the nextterraform init. Always pin to a specific tag or commit SHA. -
Duplicating provider configuration inside modules. Configuring a provider (region, credentials, default tags) inside a child module overrides the caller's provider and creates surprising behavior. Modules should inherit providers from their caller.
-
Over-abstracting trivial resources. Wrapping a single
aws_s3_bucketin a module with pass-through variables adds indirection without value. Modules should encapsulate meaningful complexity, not serve as a naming convention layer. -
Exposing every attribute as an output. Outputting every possible attribute from every resource creates a cluttered interface and encourages tight coupling. Output only the values that consumers actually need to wire into other resources or modules.
Common Pitfalls
- Nesting modules too deeply. Three or more levels of module nesting makes debugging state paths painful. Keep the hierarchy shallow.
- Passing providers into modules incorrectly. If a module needs a non-default provider (e.g., a different AWS region), use the
providersmeta-argument explicitly. - Changing module source without running
terraform init -upgrade. Terraform caches module sources. After changing a version or source URL, always re-initialize. - Outputting sensitive values without marking them. If a module output contains a secret, set
sensitive = trueon the output to prevent it from appearing in CLI output. - Using
counton module blocks whenfor_eachwould be safer. Just like resources, reordering a list used withcountcauses unnecessary recreation of entire module instances. - Tightly coupling modules via remote state. Prefer passing values explicitly through variables over reading another module's state with
terraform_remote_state. Explicit wiring is easier to trace and test.
Install this skill directly: skilldb add terraform-skills
Related Skills
Terraform Basics
Terraform fundamentals including providers, resources, data sources, and core workflow
Terraform CI CD Pipeline
Running Terraform in CI/CD pipelines with automated plan, approval gates, and safe apply workflows
Terraform Provisioners
Terraform provisioners, null resources, triggers, and when to use alternatives
Terraform State Management
Remote state backends, state locking, import, migration, and state surgery techniques
Terraform Testing
Testing Terraform configurations with native tests, Terratest, plan validation, and policy-as-code
Terraform Variables Outputs
Terraform variables, outputs, locals, type constraints, validation, and data flow patterns