Modules & Reusability
Copy-pasting Terraform configurations across environments and projects creates a maintenance nightmare — a bug fix in one copy doesn't propagate to the others, and subtle configuration drift accumulates over time. Modules are Terraform's solution: they package configurations into reusable units with well-defined inputs and outputs, letting you compose complex infrastructure from tested building blocks.
What Is a Module?
A module is any directory containing Terraform .tf files. You have been using modules from the start — your working directory is the root module. When you call another module from your configuration, it becomes a child module.
Modules serve several purposes:
- Reuse — write a VPC module once, call it for dev, staging, and prod
- Encapsulation — callers work with inputs and outputs, not internal resource details
- Versioning — pin to a known-good version; upgrade deliberately
- Composition — assemble complex infrastructure from small, focused modules
Module Structure
A well-structured module typically has these files:
| File | Purpose |
|---|---|
main.tf | Core resources the module manages |
variables.tf | Input variable declarations |
outputs.tf | Output value declarations |
versions.tf | terraform {} block with required_providers |
README.md | Usage docs, input/output reference |
Here is a minimal VPC module:
# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for all resources"
type = string
}
variable "cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "Availability zones for subnets"
type = list(string)
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, { Name = var.name })
}
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr, 8, count.index)
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, { Name = "${var.name}-public-${count.index + 1}" })
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, { Name = var.name })
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
Calling Modules
Call a module with a module block. The source argument is required — it tells Terraform where to find the module code:
# root module: main.tf
module "vpc" {
source = "./modules/vpc" # local path
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
tags = { Environment = "prod", ManagedBy = "terraform" }
}
# Access outputs with module..
After adding or changing a source, run terraform init again — Terraform downloads or refreshes the module source.
module.<name>.<output>
Resources inside a module are not directly addressable from the calling configuration. You can only access what the module explicitly exposes via output blocks. This is intentional — it enforces a clear interface and allows you to refactor module internals without breaking callers.
Module Sources
The source argument accepts several formats:
Local paths
source = "./modules/vpc"
source = "../shared/security-groups"
Relative paths starting with ./ or ../. Terraform reads the files directly — no download step. Best for modules within the same repo.
Terraform Registry
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
Format: <NAMESPACE>/<MODULE>/<PROVIDER>. These come from registry.terraform.io. The version argument is required for registry sources.
GitHub / Git
# HTTPS
source = "github.com/myorg/terraform-modules//vpc"
# SSH
source = "git@github.com:myorg/terraform-modules.git//vpc"
# Pin to a tag or commit
source = "github.com/myorg/terraform-modules//vpc?ref=v2.1.0"
The double-slash // separates the repository URL from the subdirectory within it.
S3 / GCS buckets
source = "s3::https://my-bucket.s3.amazonaws.com/modules/vpc-2.0.zip"
source = "gcs::https://www.googleapis.com/storage/v1/my-bucket/vpc-2.0.zip"
Useful for private module archives in object storage. Terraform downloads and extracts the archive.
Inputs & Outputs
Module inputs are just variable blocks. Module outputs are output blocks. The pattern is the same as root-module variables and outputs, but the context changes: inputs are values the caller provides; outputs are values the module exposes.
Required vs optional inputs
variable "cluster_name" {
description = "EKS cluster name"
type = string
# No default — required. Caller must provide it.
}
variable "node_count" {
description = "Number of worker nodes"
type = number
default = 2 # Optional — caller can override or accept default
}
variable "instance_type" {
description = "EC2 instance type for worker nodes"
type = string
default = "t3.medium"
}
Passing outputs between modules
module "network" {
source = "./modules/vpc"
name = "prod"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}
module "database" {
source = "./modules/rds"
name = "prod-db"
# Feed network outputs into database inputs
vpc_id = module.network.vpc_id
subnet_ids = module.network.public_subnet_ids
}
Terraform resolves this dependency graph automatically — module.database waits for module.network to apply before it runs.
Terraform Registry
The Terraform Registry hosts thousands of community and HashiCorp-maintained modules. The most widely used are the official AWS, Google Cloud, and Azure module collections maintained by the terraform-aws-modules organization.
# Managed VPC module — handles subnets, route tables, NAT gateways, and more
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
name = "my-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 = true
tags = {
Terraform = "true"
Environment = "dev"
}
}
# EKS cluster module
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = "my-cluster"
cluster_version = "1.30"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}
Every Registry module page lists its required and optional inputs, outputs, and any resources it creates. Read these before calling a new module — some modules create dozens of resources and have strong opinions about naming conventions, IAM policies, or security group rules that may not match your requirements.
Module Versions
For Registry and Git sources, always pin to a specific version. Unpinned modules will pull the latest version on every terraform init -upgrade, which can introduce breaking changes silently.
# Good — pinned to a minor version range
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8" # allows 5.8.x patch updates, not 6.x
}
# Better — exact pin for maximum reproducibility
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
}
Terraform records exact module versions in the .terraform.lock.hcl file. Commit this file to version control so the team always uses identical versions:
# .terraform.lock.hcl (commit this)
provider "registry.terraform.io/hashicorp/aws" {
version = "5.40.0"
constraints = "~> 5.0"
hashes = [
"h1:...",
]
}
To upgrade to a newer module version:
# Update version in module block, then:
terraform init -upgrade
# Review the plan carefully — new module versions may change resource attributes
terraform plan
Module Composition
Real infrastructure is built by composing multiple focused modules. A common pattern is a three-layer hierarchy:
infrastructure/
├── modules/
│ ├── vpc/ # Networking layer
│ ├── eks/ # Compute layer
│ └── rds/ # Data layer
├── environments/
│ ├── dev/
│ │ ├── main.tf # Calls modules with dev-sized inputs
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf # Calls same modules with prod-sized inputs
│ └── terraform.tfvars
└── global/
└── iam/ # Account-wide resources
Each environment directory calls the shared modules with environment-appropriate inputs:
# environments/prod/main.tf
module "network" {
source = "../../modules/vpc"
name = "prod"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
module "cluster" {
source = "../../modules/eks"
name = "prod-cluster"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
node_count = 6
instance_type = "m5.xlarge"
}
module "db" {
source = "../../modules/rds"
name = "prod-db"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
instance_class = "db.r5.large"
multi_az = true
}
# environments/dev/main.tf — same modules, smaller inputs
module "network" {
source = "../../modules/vpc"
name = "dev"
cidr = "10.1.0.0/16"
azs = ["us-east-1a"]
}
module "cluster" {
source = "../../modules/eks"
name = "dev-cluster"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
node_count = 2
instance_type = "t3.medium"
}
module "db" {
source = "../../modules/rds"
name = "dev-db"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
instance_class = "db.t3.micro"
multi_az = false
}
A module that manages networking, compute, and databases simultaneously is hard to test, version, and reuse. Aim for modules that do one thing — a VPC module creates networking, not EC2 instances. Compose at the root or environment level. The rule of thumb: if you can't describe the module's purpose in one sentence, it's doing too much.
Module for_each
Call a module multiple times using for_each:
variable "environments" {
default = {
dev = { cidr = "10.0.0.0/16", size = "small" }
prod = { cidr = "10.1.0.0/16", size = "large" }
}
}
module "vpc" {
for_each = var.environments
source = "./modules/vpc"
name = each.key
cidr = each.value.cidr
}
# Access specific instances:
# module.vpc["dev"].vpc_id
# module.vpc["prod"].vpc_id
Key Takeaways
- A module is any directory of
.tffiles. Your working directory is always the root module. - Call modules with a
moduleblock. Setsourceto a local path, Registry address, or Git URL. Always pin Registry sources withversion. - Module inputs are
variableblocks; outputs areoutputblocks. Callers access outputs viamodule.<name>.<output>. - Run
terraform initafter adding or changing asource. Commit.terraform.lock.hclto lock versions for the whole team. - The Terraform Registry has production-grade modules for AWS, GCP, Azure, and more. Read the module's docs before adopting it.
- Compose infrastructure from focused, single-purpose modules. Use environment directories to call the same modules with different inputs.