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 documentation

The 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.