Terraform

Terraform Modules: Writing, Testing, and Reusing Infrastructure Code

By Raghvendra Pandey · June 2026 · 10 min read

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, < 6.0"
    }
  }
}

Using >= 5.0, < 6.0 (a pessimistic version constraint) is safer than ~> 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:

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:

Poor candidates:

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