Terraform modules encapsulate infrastructure patterns into reusable, testable, and versionable components. Instead of copying and pasting configuration blocks, modules let you define infrastructure once and instantiate it across environments and projects. This guide covers creating, structuring, and managing Terraform modules for production use.
Module Structure
# Standard module directory layout
modules/
├── vpc/
│ ├── main.tf # Resources
│ ├── variables.tf # Input variables
│ ├── outputs.tf # Output values
│ ├── versions.tf # Provider requirements
│ └── README.md # Documentation
├── web-server/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── templates/
│ └── user-data.sh
└── database/
├── main.tf
├── variables.tf
└── outputs.tf
Creating a Module
# modules/web-server/variables.tf
variable "name" {
description = "Name prefix for resources"
type = string
}
variable "instance_type" {
description = "Server instance type"
type = string
default = "t3.micro"
}
variable "vpc_id" {
description = "VPC ID to deploy into"
type = string
}
variable "subnet_ids" {
description = "Subnet IDs for the instances"
type = list(string)
}
variable "min_size" {
description = "Minimum number of instances"
type = number
default = 1
}
variable "max_size" {
description = "Maximum number of instances"
type = number
default = 3
}
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
# modules/web-server/main.tf
resource "aws_security_group" "web" {
name_prefix = "${var.name}-web-"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.name}-web-sg"
Environment = var.environment
}
}
resource "aws_launch_template" "web" {
name_prefix = "${var.name}-web-"
image_id = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
user_data = base64encode(templatefile("${path.module}/templates/user-data.sh", {
environment = var.environment
}))
tag_specifications {
resource_type = "instance"
tags = { Name = "${var.name}-web", Environment = var.environment }
}
}
resource "aws_autoscaling_group" "web" {
name = "${var.name}-web-asg"
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.min_size
vpc_zone_identifier = var.subnet_ids
launch_template {
id = aws_launch_template.web.id
version = "$Latest"
}
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-24.04-amd64-server-*"]
}
}
# modules/web-server/outputs.tf
output "security_group_id" {
description = "Security group ID for the web servers"
value = aws_security_group.web.id
}
output "asg_name" {
description = "Auto Scaling Group name"
value = aws_autoscaling_group.web.name
}
Using the Module
# environments/production/main.tf
module "web_server" {
source = "../../modules/web-server"
name = "myapp"
instance_type = "t3.medium"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
min_size = 2
max_size = 6
environment = "production"
}
# Can instantiate multiple times
module "api_server" {
source = "../../modules/web-server"
name = "myapi"
instance_type = "t3.large"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
min_size = 3
max_size = 10
environment = "production"
}
Module Versioning
# Use Git tags for module versions
module "web_server" {
source = "git::https://github.com/myorg/terraform-modules.git//web-server?ref=v1.2.0"
}
# Or Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
# Local module (no versioning, changes immediately)
module "web_server" {
source = "../modules/web-server"
}
Testing Modules
# Use Terratest (Go) for integration testing
# test/web_server_test.go
func TestWebServerModule(t *testing.T) {
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../examples/web-server",
Vars: map[string]interface{}{
"environment": "test",
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
sgId := terraform.Output(t, terraformOptions, "security_group_id")
assert.NotEmpty(t, sgId)
}
# Or use terraform test (native, Terraform 1.6+)
# tests/web_server.tftest.hcl
run "create_web_server" {
command = apply
variables {
name = "test"
environment = "dev"
}
assert {
condition = output.security_group_id != ""
error_message = "Security group ID should not be empty"
}
}
Best Practices
- Keep modules focused: One module per logical component (VPC, web server, database)
- Use semantic versioning: Tag releases with v1.0.0, v1.1.0, v2.0.0
- Document inputs and outputs: Use description fields and generate docs with terraform-docs
- Validate inputs: Use variable validation blocks to catch errors early
- Provide examples: Include an examples/ directory showing how to use the module
- Test before release: Use Terratest or terraform test for automated validation
Summary
Terraform modules are the key to maintainable infrastructure as code at scale. By encapsulating infrastructure patterns into reusable, versioned modules with clear interfaces, you eliminate configuration drift between environments and enable teams to provision infrastructure consistently. Start by identifying repeated patterns in your Terraform code, extract them into modules, version them with Git tags, and test them before promotion to production.