DEV Community

Raghvendra Pandey
Raghvendra Pandey

Posted on • Originally published at infrasketch.cloud

Terraform Modules: Writing, Testing, and Reusing Infrastructure Code

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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     = {}
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Enter fullscreen mode Exit fullscreen mode

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.

Enter fullscreen mode Exit fullscreen mode

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:

Enter fullscreen mode Exit fullscreen mode

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 (23 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:

Enter fullscreen mode Exit fullscreen mode

// 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:

Enter fullscreen mode Exit fullscreen mode

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:

Enter fullscreen mode Exit fullscreen mode

Generate docs and update README.md

terraform-docs markdown table --output-file README.md ./modules/vpc


The generated section looks like:

Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)