Terraform

Modules & Reusability

● Intermediate ⏱ 25 min read terraform

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:

Module Structure

A well-structured module typically has these files:

FilePurpose
main.tfCore resources the module manages
variables.tfInput variable declarations
outputs.tfOutput value declarations
versions.tfterraform {} block with required_providers
README.mdUsage 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..
resource "aws_instance" "app" {
  subnet_id = module.vpc.public_subnet_ids[0]
  # ...
}

After adding or changing a source, run terraform init again — Terraform downloads or refreshes the module source.

💡
Module outputs use 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
}
🧭
Read the module's inputs before using it

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
}
⚠️
Keep modules focused

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