Every Terraform codebase eventually hits the same inflection point: the root module grows unwieldy, the same patterns repeat across environments, and someone asks "can we just reuse the VPC setup from the payment team?" Modules are Terraform's answer to reuse and encapsulation. Done well, they let you compose infrastructure from tested, versioned building blocks. Done poorly, they create abstraction layers that hide problems, make debugging harder, and need to be broken apart six months later.
This guide covers what modules actually are, how to structure them, the patterns that work at scale, and how to test them — including when you probably shouldn't extract a module at all.
What a module is
In Terraform, every directory of .tf files is a module. The directory you run terraform apply from is the root module. Any other directory of .tf files that you reference with a module block is a child module.
That's it. There's no special module declaration or module keyword inside the module directory. A module is just a collection of Terraform resources that accepts inputs via variable blocks and exposes outputs via output blocks.
# Calling a module from a root module
module "vpc" {
source = "./modules/vpc" # local path
# or:
source = "terraform-aws-modules/vpc/aws" # Terraform Registry
version = "~> 5.0"
name = "production"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
}
Module structure
A well-structured module has four files at minimum:
modules/vpc/
├── main.tf # resource definitions
├── variables.tf # input variable declarations
├── outputs.tf # output value declarations
└── versions.tf # required_providers and terraform version constraint
Optional but common additions:
modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md # documents inputs, outputs, usage examples
└── examples/
└── complete/ # a working example that calls the module
variables.tf
Variables are the public interface of a module. Every variable should have a description and a type. Use default for optional variables; omit it for required ones.
variable "name" {
description = "Name prefix for all resources created by this module."
type = string
}
variable "cidr_block" {
description = "CIDR block for the VPC."
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "enable_nat_gateway" {
description = "Whether to create a NAT Gateway in each availability zone."
type = bool
default = true
}
variable "tags" {
description = "Tags to apply to all resources."
type = map(string)
default = {}
}
The validation block runs before any resource creation and gives users a useful error immediately instead of an obscure provider error later.
outputs.tf
Outputs expose values that callers need. Be generous with outputs — it's easier to add an output than to break consumers who discover they needed one.
output "vpc_id" {
description = "The ID of the VPC."
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "List of IDs of private subnets."
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
description = "List of IDs of public subnets."
value = aws_subnet.public[*].id
}
output "nat_gateway_ids" {
description = "List of NAT Gateway IDs."
value = aws_nat_gateway.main[*].id
}
versions.tf
Constrain the Terraform version and provider versions to prevent incompatible configurations:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, = 5.0, 5.0` for modules — it prevents a major version bump from breaking users who haven't explicitly upgraded.
## Local modules vs registry modules
Local modules (relative paths like `./modules/vpc`) are for internal organizational abstractions. They live in your repo, version-controlled alongside the infrastructure that uses them. Advantages: fast iteration, no publishing step, easy to understand the full codebase as a unit. Disadvantages: can't be shared across repos without copy-pasting.
Registry modules are published to the Terraform Registry (public) or a private registry (Terraform Cloud, Artifactory, etc.). They're pinned by version and fetched by `terraform init`. The key attributes of a good registry module:
- Semantic versioning with a clear changelog
- Well-documented inputs and outputs
- Working examples in `examples/`
- Automated tests
- Maintained and responding to issues
The community modules at `terraform-aws-modules` on GitHub are the gold standard. The `terraform-aws-modules/vpc/aws` module, for example, manages hundreds of resource configurations across a very large variable surface. It's an excellent reference for how to structure a complex module.
## Version pinning
Always pin module versions in production. Unpinned modules pull the latest version on `terraform init -upgrade`, which can introduce breaking changes silently.
Good — pinned to a specific minor version
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1" # allows 5.1.x, not 5.2.x or 6.x
...
}
Bad — no version constraint
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version omitted — gets latest on terraform init -upgrade
}
Use `~> X.Y` (allows patch updates within the minor version) for registry modules. Only upgrade to a new minor or major version intentionally.
## Module composition patterns
### Flat composition
The simplest and most common pattern: the root module calls several child modules, each responsible for one infrastructure concern.
module "vpc" {
source = "./modules/vpc"
name = var.environment
cidr = "10.0.0.0/16"
}
module "eks" {
source = "./modules/eks"
cluster_name = "${var.environment}-cluster"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
module "rds" {
source = "./modules/rds"
name = "${var.environment}-db"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
Outputs from one module become inputs to another. This dependency chain is explicit — Terraform builds a dependency graph and applies modules in the right order.
### Wrapper modules
A wrapper module adds organizational defaults on top of a community module:
modules/vpc-standard/main.tf
Internal module that wraps the community VPC module
with company-standard settings
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1"
name = var.name
cidr = var.cidr
Company standard: always enable DNS, always tag
enable_dns_hostnames = true
enable_dns_support = true
enable_nat_gateway = true
single_nat_gateway = var.environment != "production"
tags = merge(
var.tags,
{
ManagedBy = "terraform"
Environment = var.environment
Team = var.team
}
)
}
Consumers call the wrapper instead of the community module directly. When the company standard changes (add a required tag, change a default), update the wrapper and all users get the change.
## When to extract a module — and when not to
A common mistake: extracting modules too early. If a set of resources is only used once, extracting it to a module adds abstraction overhead without the reuse benefit. The rule of thumb: extract a module when you actually need to reuse it in a second place, not in anticipation of possible reuse.
Good candidates for extraction:
- The same VPC pattern deployed in multiple environments (dev, staging, prod)
- A standard RDS setup used by multiple application teams
- A "microservice" pattern (ECS task + ALB target group + CloudWatch alarms) used for every service
- Anything with complex interdependencies that benefit from hiding implementation details
Poor candidates:
- A one-off resource with unique configuration (extract is pure overhead)
- Resources that need to be modified differently in each environment (module abstraction fights the customization)
- Very small sets of resources (2–3 resources don't need a module)
## Testing modules with Terratest
Terratest is a Go library for writing automated tests for infrastructure code. A Terratest test deploys real infrastructure, runs assertions against it, and tears it down:
// modules/vpc/tests/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/stretchr/testify/assert"
)
func TestVpcModule(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../examples/complete",
Vars: map[string]interface{}{
"name": "test-vpc",
"region": "us-east-1",
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
vpcId := terraform.Output(t, opts, "vpc_id")
assert.NotEmpty(t, vpcId)
// Verify the VPC actually exists in AWS
vpc := aws.GetVpcById(t, vpcId, "us-east-1")
assert.Equal(t, "10.0.0.0/16", aws.GetVpcCidrBlock(t, vpcId, "us-east-1"))
assert.True(t, *vpc.EnableDnsHostnames)
}
Terratest tests are slow (they deploy real infrastructure) and cost money. Reserve them for modules that will be shared and relied upon by multiple teams. For most internal modules, a combination of `terraform validate`, `terraform plan` output review in CI, and manual testing is sufficient.
For lightweight unit testing without deploying infrastructure, `terraform test` (built into Terraform 1.6+) lets you write test files that exercise module logic with mock providers:
modules/vpc/tests/input_validation.tftest.hcl
run "invalid_cidr_block" {
command = plan
variables {
name = "test"
cidr_block = "not-a-cidr"
}
expect_failures = [var.cidr_block]
}
## Module documentation
`terraform-docs` is a tool that reads your module's `variables.tf` and `outputs.tf` and generates a markdown table of inputs and outputs. Running it in CI keeps documentation in sync with the code:
Generate docs and update README.md
terraform-docs markdown table --output-file README.md ./modules/vpc
The generated section looks like:
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| name | Name prefix for all resources | string |
n/a | yes |
| cidr_block | CIDR block for the VPC | string |
"10.0.0.0/16" |
no |
Outputs
| Name | Description |
|---|---|
| vpc_id | The ID of the VPC |
| private_subnet_ids | List of private subnet IDs |
When a module is consumed by other teams, this documentation is the contract. Good documentation reduces questions and prevents misuse.
To visualize the resources a Terraform module creates and how they relate — useful during module design or review — paste the module's HCL into [InfraSketch](/) to see the architecture diagram. Module calls in a root module appear as labeled groups, with each resource inside the module shown individually.
## Related articles
- [Terraform State Explained: What It Is, How It Works, and Why It Breaks](/blog/terraform-state-explained.html)
- [Terraform Visualization: 5 Ways to See What Your Code Actually Builds](/blog/terraform-visualization-best-practices.html)
- [Terraform vs CDK vs Pulumi: Choosing Your IaC Tool](/blog/iac-tool-comparison.html)
Top comments (0)