Introduction
Terraform modules are the cornerstone of reusable, maintainable infrastructure-as-code. Without modules, you end up copying and pasting Terraform code across projects, making global changes a nightmare. With well-designed modules, you define infrastructure patterns once and reuse them with different parameters. This guide teaches you to write, publish, and consume Terraform modules professionally.
What Is a Terraform Module?
Any directory containing .tf files is a module. When you write Terraform code without modules, you're using the "root module." When you call a module from your root module using module blocks, you're using a child module.
project/
├── main.tf ← root module
├── variables.tf
├── outputs.tf
└── modules/
├── vpc/ ← child module
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── ec2/ ← another child module
├── main.tf
├── variables.tf
└── outputs.tfBuilding Your First Reusable Module
Create a reusable AWS security group module:
# modules/security-group/variables.tf
variable "name" {
description = "Name of the security group"
type = string
}
variable "vpc_id" {
description = "VPC ID to create the security group in"
type = string
}
variable "ingress_rules" {
description = "List of ingress rules"
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = []
}
variable "egress_allow_all" {
description = "Whether to allow all outbound traffic"
type = bool
default = true
}
variable "tags" {
description = "Tags to apply to the security group"
type = map(string)
default = {}
}# modules/security-group/main.tf
resource "aws_security_group" "this" {
name = var.name
description = "Managed by Terraform module"
vpc_id = var.vpc_id
tags = merge(
{ "Name" = var.name },
var.tags
)
}
resource "aws_security_group_rule" "ingress" {
for_each = { for i, rule in var.ingress_rules : i => rule }
type = "ingress"
security_group_id = aws_security_group.this.id
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
description = each.value.description
}
resource "aws_security_group_rule" "egress_all" {
count = var.egress_allow_all ? 1 : 0
type = "egress"
security_group_id = aws_security_group.this.id
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}# modules/security-group/outputs.tf
output "id" {
description = "Security group ID"
value = aws_security_group.this.id
}
output "arn" {
description = "Security group ARN"
value = aws_security_group.this.arn
}Using the Module
# root main.tf
module "web_sg" {
source = "./modules/security-group" # Local path
name = "web-tier-sg"
vpc_id = module.vpc.vpc_id
ingress_rules = [
{
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP from internet"
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS from internet"
}
]
tags = {
Environment = "production"
Tier = "web"
}
}
module "app_sg" {
source = "./modules/security-group"
name = "app-tier-sg"
vpc_id = module.vpc.vpc_id
ingress_rules = [
{
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = [module.vpc.private_subnet_cidrs[0]]
description = "App traffic from web tier"
}
]
}
# Reference module outputs
resource "aws_lb_target_group" "web" {
vpc_id = module.vpc.vpc_id
# ...
}
resource "aws_instance" "web" {
vpc_security_group_ids = [module.web_sg.id]
# ...
}Module Versioning with Git Tags
# Tag a module version
git tag v1.0.0
git push origin v1.0.0
# Use a specific version in root module
module "security_group" {
source = "git::https://github.com/company/tf-modules.git//security-group?ref=v1.0.0"
# ...
}
# Or using SSH
module "security_group" {
source = "git@github.com:company/tf-modules.git//security-group?ref=v1.2.0"
# ...
}Publishing to Terraform Registry
# Module naming convention for public registry:
terraform-<PROVIDER>-<NAME>
# Example: terraform-aws-security-group
# Required structure for registry:
terraform-aws-security-group/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md ← Required, auto-generated docs
├── LICENSE
└── examples/
└── complete/
├── main.tf ← Working example
└── README.md
# Publish:
# 1. Push to GitHub as public repo
# 2. Create GitHub release with semver tag (v1.0.0)
# 3. Sign into registry.terraform.io with GitHub
# 4. Click "Publish Module"Advanced Module Patterns
Conditional Resources:# modules/rds/main.tf
variable "create_read_replica" {
type = bool
default = false
}
resource "aws_db_instance" "primary" {
# main database config...
}
resource "aws_db_instance" "replica" {
count = var.create_read_replica ? 1 : 0
replicate_source_db = aws_db_instance.primary.id
# replica config...
}variable "subnets" {
type = list(object({
name = string
cidr = string
az = string
}))
}
resource "aws_subnet" "this" {
for_each = { for s in var.subnets : s.name => s }
vpc_id = aws_vpc.this.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = { "Name" = each.value.name }
}# Compose higher-level modules from primitives
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
}
module "eks_cluster" {
source = "./modules/eks"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
module "rds" {
source = "./modules/rds"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.database_subnet_ids
# Allow EKS to connect to RDS
allowed_security_group_ids = [module.eks_cluster.node_security_group_id]
}Testing Modules with Terratest
// test/security_group_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestSecurityGroupModule(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../examples/complete",
Vars: map[string]interface{}{
"name": "test-sg",
"vpc_id": "vpc-12345678",
},
}
// Cleanup after test
defer terraform.Destroy(t, opts)
// Apply
terraform.InitAndApply(t, opts)
// Validate output
sgId := terraform.Output(t, opts, "id")
assert.NotEmpty(t, sgId)
assert.Regexp(t, "^sg-", sgId)
}Module Documentation Best Practices
# terraform-aws-security-group
Creates AWS Security Groups with configurable ingress/egress rules.
## Usagemodule "security_group" {
source = "company/security-group/aws"
version = "~> 1.0"
name = "web-sg"
vpc_id = "vpc-12345"
ingress_rules = [
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS"
}
]
}
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.0 |
| aws | ~> 5.0 |
## Inputs
| Name | Description | Type | Required |
|------|------------|------|----------|
| name | Security group name | `string` | yes |
| vpc_id | VPC ID | `string` | yes |
| ingress_rules | Ingress rules | `list(object)` | no |
## Outputs
| Name | Description |
|------|------------|
| id | Security group ID |
| arn | Security group ARN |Reusable modules are the key to maintaining infrastructure at scale. Start with modules for your most-duplicated patterns, version them strictly, and always write examples that serve as both documentation and integration tests.
