Modules

Create reusable infrastructure components with Terraform modules

9 min read

Modules

In the previous tutorial, we learned expressions and functions — making configs dynamic and smart. Now let's fix the biggest problem in Terraform projects: copy-pasting code everywhere.

Copy-pasting Terraform code between projects? Stop. Please. Modules let you package infrastructure into reusable components. Define it once, use it everywhere. It's like writing a function instead of copying the same 50 lines all over the place.

What's a Module?

"Is a module some complicated thing I have to learn?"

Nope. A module is just a directory with Terraform files. That's it. Seriously.

modules/
└── ec2-instance/
    ā”œā”€ā”€ main.tf
    ā”œā”€ā”€ variables.tf
    └── outputs.tf

Your root configuration is also a module — the "root module." You've been using modules this whole time without knowing it. How's that for a plot twist?

Your First Module

Let's create a reusable EC2 instance module. Once this exists, spinning up a new server takes about 5 lines.

Module Structure

modules/ec2-instance/
ā”œā”€ā”€ main.tf       # Resources
ā”œā”€ā”€ variables.tf  # Inputs
└── outputs.tf    # Outputs

variables.tf

variable "name" {
  description = "Name for the instance"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}

variable "ami_id" {
  description = "AMI ID to use"
  type        = string
}

variable "subnet_id" {
  description = "Subnet to launch in"
  type        = string
}

variable "security_group_ids" {
  description = "Security groups to attach"
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Additional tags"
  type        = map(string)
  default     = {}
}

main.tf

resource "aws_instance" "this" {
  ami                    = var.ami_id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = var.security_group_ids

  tags = merge(var.tags, {
    Name = var.name
  })
}

outputs.tf

output "id" {
  description = "Instance ID"
  value       = aws_instance.this.id
}

output "public_ip" {
  description = "Public IP address"
  value       = aws_instance.this.public_ip
}

output "private_ip" {
  description = "Private IP address"
  value       = aws_instance.this.private_ip
}

Using a Module

"Great, I built a module. Now how do I use it?"

Local Module

module "web_server" {
  source = "./modules/ec2-instance"

  name               = "web-server"
  instance_type      = "t2.small"
  ami_id             = data.aws_ami.ubuntu.id
  subnet_id          = aws_subnet.public.id
  security_group_ids = [aws_security_group.web.id]

  tags = {
    Environment = "production"
    Team        = "platform"
  }
}

# Access module outputs
output "web_server_ip" {
  value = module.web_server.public_ip
}

Multiple Instances

"What if I need 3 web servers from the same module?"

Combine with for_each:

module "web_servers" {
  source   = "./modules/ec2-instance"
  for_each = toset(["web-1", "web-2", "web-3"])

  name               = each.key
  ami_id             = data.aws_ami.ubuntu.id
  subnet_id          = aws_subnet.public.id
  security_group_ids = [aws_security_group.web.id]
}

# All IPs
output "web_ips" {
  value = {for name, instance in module.web_servers : name => instance.public_ip}
}

Module Sources

Modules can come from anywhere — your laptop, GitHub, the Terraform Registry, even an S3 bucket.

Local Path

module "vpc" {
  source = "./modules/vpc"
}

module "shared" {
  source = "../shared-modules/database"
}

Git Repository

module "vpc" {
  source = "git::https://github.com/your-org/terraform-modules.git//vpc"
}

# Specific branch
module "vpc" {
  source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=main"
}

# Specific tag
module "vpc" {
  source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=v1.2.0"
}

# SSH
module "vpc" {
  source = "git@github.com:your-org/terraform-modules.git//vpc?ref=v1.2.0"
}

Terraform Registry

"Isn't there a library of pre-built modules?"

Yes! The Terraform Registry has thousands of community modules. Why build a VPC from scratch when someone already did it?

# Public registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
}

That VPC module has been downloaded millions of times. Battle-tested by the community.

# Private registry
module "internal" {
  source  = "app.terraform.io/your-org/module-name/provider"
  version = "1.0.0"
}

S3 Bucket

module "vpc" {
  source = "s3::https://s3-us-west-2.amazonaws.com/your-bucket/vpc.zip"
}

Version Constraints

"What if a module update breaks my stuff?"

Pin your versions. Always.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"           # Exact version
}

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"         # >= 19.0.0 and < 20.0.0
}

module "rds" {
  source  = "terraform-aws-modules/rds/aws"
  version = ">= 5.0, < 6.0"   # Range
}

Always pin versions in production. A ~> is usually the right balance — gets you patches but not breaking changes.

Practical Module: VPC

Let's build something more substantial — a reusable VPC module with public/private subnets and an optional NAT Gateway.

modules/vpc/variables.tf

variable "name" {
  description = "VPC name"
  type        = string
}

variable "cidr" {
  description = "VPC CIDR block"
  type        = string
  default     = "10.0.0.0/16"
}

variable "azs" {
  description = "Availability zones"
  type        = list(string)
}

variable "public_subnets" {
  description = "Public subnet CIDRs"
  type        = list(string)
  default     = []
}

variable "private_subnets" {
  description = "Private subnet CIDRs"
  type        = list(string)
  default     = []
}

variable "enable_nat" {
  description = "Enable NAT Gateway"
  type        = bool
  default     = false
}

variable "tags" {
  description = "Tags for 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_internet_gateway" "this" {
  count  = length(var.public_subnets) > 0 ? 1 : 0
  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, {
    Name = "${var.name}-igw"
  })
}

resource "aws_subnet" "public" {
  count                   = length(var.public_subnets)
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnets[count.index]
  availability_zone       = var.azs[count.index % length(var.azs)]
  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name = "${var.name}-public-${count.index + 1}"
    Tier = "public"
  })
}

resource "aws_subnet" "private" {
  count             = length(var.private_subnets)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.private_subnets[count.index]
  availability_zone = var.azs[count.index % length(var.azs)]

  tags = merge(var.tags, {
    Name = "${var.name}-private-${count.index + 1}"
    Tier = "private"
  })
}

# NAT Gateway (optional)
resource "aws_eip" "nat" {
  count  = var.enable_nat ? 1 : 0
  domain = "vpc"

  tags = merge(var.tags, {
    Name = "${var.name}-nat-eip"
  })
}

resource "aws_nat_gateway" "this" {
  count         = var.enable_nat ? 1 : 0
  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id

  tags = merge(var.tags, {
    Name = "${var.name}-nat"
  })
}

# Route tables
resource "aws_route_table" "public" {
  count  = length(var.public_subnets) > 0 ? 1 : 0
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this[0].id
  }

  tags = merge(var.tags, {
    Name = "${var.name}-public-rt"
  })
}

resource "aws_route_table" "private" {
  count  = length(var.private_subnets) > 0 ? 1 : 0
  vpc_id = aws_vpc.this.id

  dynamic "route" {
    for_each = var.enable_nat ? [1] : []
    content {
      cidr_block     = "0.0.0.0/0"
      nat_gateway_id = aws_nat_gateway.this[0].id
    }
  }

  tags = merge(var.tags, {
    Name = "${var.name}-private-rt"
  })
}

resource "aws_route_table_association" "public" {
  count          = length(var.public_subnets)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public[0].id
}

resource "aws_route_table_association" "private" {
  count          = length(var.private_subnets)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[0].id
}

modules/vpc/outputs.tf

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.this.id
}

output "vpc_cidr" {
  description = "VPC CIDR block"
  value       = aws_vpc.this.cidr_block
}

output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "Private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "nat_gateway_ip" {
  description = "NAT Gateway public IP"
  value       = var.enable_nat ? aws_eip.nat[0].public_ip : null
}

Using the VPC Module

module "vpc" {
  source = "./modules/vpc"

  name = "production"
  cidr = "10.0.0.0/16"
  azs  = ["us-west-2a", "us-west-2b", "us-west-2c"]

  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat = true

  tags = {
    Environment = "production"
    Project     = "myapp"
  }
}

# Use outputs
resource "aws_instance" "web" {
  subnet_id = module.vpc.public_subnet_ids[0]
  # ...
}

Module Composition

"Can modules use other modules?"

Absolutely. That's called composition, and it's where things get really powerful.

Nested Modules

modules/
ā”œā”€ā”€ vpc/
ā”œā”€ā”€ security-group/
└── web-stack/
    ā”œā”€ā”€ main.tf      # Uses vpc and security-group modules
    ā”œā”€ā”€ variables.tf
    └── outputs.tf
# modules/web-stack/main.tf
module "vpc" {
  source = "../vpc"
  name   = var.name
  cidr   = var.vpc_cidr
  # ...
}

module "web_sg" {
  source = "../security-group"
  vpc_id = module.vpc.vpc_id
  # ...
}

resource "aws_instance" "web" {
  subnet_id              = module.vpc.public_subnet_ids[0]
  vpc_security_group_ids = [module.web_sg.id]
  # ...
}

Passing Providers

# For multi-region or multi-account
provider "aws" {
  alias  = "us_east"
  region = "us-east-1"
}

provider "aws" {
  alias  = "us_west"
  region = "us-west-2"
}

module "vpc_east" {
  source = "./modules/vpc"
  providers = {
    aws = aws.us_east
  }
  # ...
}

module "vpc_west" {
  source = "./modules/vpc"
  providers = {
    aws = aws.us_west
  }
  # ...
}

Module Best Practices

The wisdom section. Learn from other people's tightly-coupled spaghetti modules.

1. Keep It Focused

# Good: Single responsibility
modules/
ā”œā”€ā”€ vpc/
ā”œā”€ā”€ security-group/
ā”œā”€ā”€ ec2-instance/
└── rds/

# Bad: Kitchen sink
modules/
└── entire-application/  # Does everything

2. Sensible Defaults

variable "instance_type" {
  default = "t2.micro"  # Reasonable default
}

variable "enable_monitoring" {
  default = true  # Secure by default
}

variable "backup_retention" {
  default = 7  # Safe default
}

3. Validate Inputs

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "instance_type" {
  type = string
  validation {
    condition     = can(regex("^t[23]\\.", var.instance_type))
    error_message = "Only t2 or t3 instance types allowed."
  }
}

4. Document Everything

"But writing docs is boring."

So is debugging a module with zero documentation at 2 AM.

variable "vpc_cidr" {
  description = <<-EOT
    CIDR block for the VPC. Must be at least /16 to accommodate
    all subnets. Example: 10.0.0.0/16
  EOT
  type = string
}

5. Consistent Naming

# Resource naming convention
resource "aws_instance" "this" {  # "this" for the main resource
  # ...
}

# Output naming
output "id" { }           # Not "instance_id"
output "arn" { }          # Not "instance_arn"
output "security_group_id" { }  # Explicit for related resources

Publishing Modules

Terraform Registry Requirements

terraform-<PROVIDER>-<NAME>/
ā”œā”€ā”€ README.md         # Required
ā”œā”€ā”€ main.tf           # Required
ā”œā”€ā”€ variables.tf      # Required
ā”œā”€ā”€ outputs.tf        # Required
ā”œā”€ā”€ versions.tf       # Provider requirements
ā”œā”€ā”€ examples/
│   └── simple/
│       └── main.tf
└── modules/
    └── submodule/

versions.tf

terraform {
  required_version = ">= 1.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 4.0"
    }
  }
}

What's Next?

You just unlocked the key to scalable Terraform. No more copy-paste, no more snowflake projects. You learned:

  • Module structure (just a directory with .tf files!)
  • Using local, Git, and registry modules
  • Version pinning for safety
  • A real VPC module you can actually use
  • Best practices that'll save you headaches

Now let's zoom out and talk about project structure — how to organize all these files for real-world projects. Let's go!