Skip to main content
Technology & EngineeringTerraform289 lines

Terraform Modules

Terraform module design, composition, versioning, and reuse patterns

Quick Summary18 lines
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 lines
Paste into your CLAUDE.md or agent config

Modules — 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 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.

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 main branch. Using source = "git::https://...?ref=main" in production means any push to the module repository can silently change your infrastructure on the next terraform 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_bucket in 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 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.
  • Using count on module blocks when for_each would be safer. Just like resources, reordering a list used with count causes 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

Get CLI access →