What Modules Are#
A Terraform module is a directory containing .tf files. Every Terraform configuration is already a module (the “root module”). When you call another module from your root module, that is a “child module.” Modules let you encapsulate a set of resources behind a clean interface of input variables and outputs.
Module Structure#
A well-organized module looks like this:
modules/vpc/
main.tf # resource definitions
variables.tf # input variables
outputs.tf # output values
versions.tf # required providers and terraform version
README.md # usage documentationThe module itself has no backend, no provider configuration, and no hardcoded values. Everything configurable comes in through variables. Everything downstream consumers need comes out through outputs.
# modules/vpc/variables.tf
variable "name" {
type = string
description = "Name prefix for all VPC resources"
}
variable "cidr" {
type = string
description = "CIDR block for the VPC"
}
variable "private_subnets" {
type = list(string)
description = "List of private subnet CIDR blocks"
}
variable "public_subnets" {
type = list(string)
description = "List of public subnet CIDR blocks"
}
variable "azs" {
type = list(string)
description = "Availability zones"
}# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = true
tags = { Name = var.name }
}
resource "aws_subnet" "private" {
for_each = { for i, cidr in var.private_subnets : var.azs[i] => cidr }
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = each.key
tags = { Name = "${var.name}-private-${each.key}" }
}
resource "aws_subnet" "public" {
for_each = { for i, cidr in var.public_subnets : var.azs[i] => cidr }
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = true
tags = { Name = "${var.name}-public-${each.key}" }
}# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
}
output "public_subnet_ids" {
value = [for s in aws_subnet.public : s.id]
}Calling Modules#
From the root module, call your module and wire it to others:
module "vpc" {
source = "./modules/vpc"
name = "prod"
cidr = "10.0.0.0/16"
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
azs = ["us-east-1a", "us-east-1b"]
}
module "ecs_cluster" {
source = "./modules/ecs"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}Module Sources#
Modules can come from multiple locations:
# Local path
module "vpc" {
source = "./modules/vpc"
}
# Terraform Registry (versioned)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
}
# Git repository with tag
module "vpc" {
source = "git::https://github.com/myorg/terraform-modules.git//vpc?ref=v2.1.0"
}
# Git over SSH
module "vpc" {
source = "git::ssh://git@github.com/myorg/terraform-modules.git//vpc?ref=v2.1.0"
}Always pin versions. For registry modules, use version. For Git sources, use ?ref= with a tag. Never point at main branch for production.
Using terraform-aws-modules#
The terraform-aws-modules GitHub organization provides battle-tested modules for most AWS services. They handle edge cases you probably have not thought of:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = "prod-vpc"
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"]
enable_nat_gateway = true
single_nat_gateway = false
enable_dns_hostnames = true
}These modules handle NAT gateways, route tables, internet gateways, and tagging. Read their inputs carefully – defaults may not match your security requirements.
Module Composition Patterns#
Instead of one giant module, create focused modules and compose them:
module "vpc" { source = "./modules/vpc" ... }
module "alb" { source = "./modules/alb" vpc_id = module.vpc.vpc_id ... }
module "ecs" { source = "./modules/ecs" vpc_id = module.vpc.vpc_id ... }
module "rds" { source = "./modules/rds" subnet_ids = module.vpc.private_subnet_ids ... }The root module is the composition layer. Child modules communicate only through the root module wiring outputs to inputs.
Module Best Practices#
No hardcoded values. Every region, account ID, CIDR, name, and size should be a variable.
Explicit outputs. If a downstream module or a human might need a value, output it.
No provider blocks in child modules. Let the root module configure providers. Child modules inherit them. Pass provider aliases via the providers argument.
Validate inputs. Use validation blocks to catch errors early:
variable "cidr" {
type = string
validation {
condition = can(cidrhost(var.cidr, 0))
error_message = "Must be a valid CIDR block."
}
}Testing with Terratest#
Terratest is a Go library that applies your Terraform, validates the infrastructure, and destroys it:
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVpcModule(t *testing.T) {
opts := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"name": "test-vpc",
"cidr": "10.99.0.0/16",
"private_subnets": []string{"10.99.1.0/24"},
"public_subnets": []string{"10.99.101.0/24"},
"azs": []string{"us-east-1a"},
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
vpcId := terraform.Output(t, opts, "vpc_id")
assert.Contains(t, vpcId, "vpc-")
}Run with go test -v -timeout 30m ./test/. Tests create real infrastructure, so run them in an isolated account. The defer terraform.Destroy ensures cleanup even if assertions fail. Reserve full Terratest runs for nightly or pre-release pipelines.