Terraform Modules
Well-designed Terraform modules enable code reuse, enforce standards, and accelerate infrastructure delivery. This guide covers module design patterns and a complete module example based on modules authored for Samsung's infrastructure platform.
Module Structure and Best Practices
A well-structured module follows a clear file organization pattern and adheres to design principles that ensure maintainability and composability:
Standard Module Layout
terraform-aws-security-group/
โโโ main.tf # Core resource definitions
โโโ variables.tf # All input variables
โโโ outputs.tf # All outputs
โโโ versions.tf # Terraform and provider version constraints
โโโ README.md # Usage documentation (required)
โโโ CHANGELOG.md # Version history
โโโ LICENSE
โโโ .terraform-docs.yml # terraform-docs configuration
โโโ examples/
โ โโโ complete/ # Comprehensive example
โ โ โโโ main.tf
โ โ โโโ variables.tf
โ โ โโโ outputs.tf
โ โ โโโ README.md
โ โโโ minimal/ # Minimal viable example
โ โโโ main.tf
โ โโโ README.md
โโโ tests/ # Terratest or kitchen-terraform
โโโ security_group_test.go
Design Principles
| Principle | Description |
|---|---|
| Single Responsibility | Each module manages one logical component (VPC, SG, database) |
| Sensible Defaults | Provide reasonable defaults so the module works with minimal inputs |
| Composability | Modules should work together; avoid hidden dependencies |
| Input Validation | Validate all inputs with validation blocks |
| Complete Outputs | Expose all resource IDs and attributes consumers might need |
| Documentation | README with usage examples, input/output tables, architecture diagram |
| Semantic Versioning | Use SemVer for releases; document breaking changes |
Root Module vs. Child Modules
# Root module (your configuration)
# main.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Child modules (reusable components)
module "vpc" {
source = "app.terraform.io/my-org/vpc/aws"
version = "~> 2.0"
name = "production"
cidr = "10.0.0.0/16"
}
module "security_group" {
source = "./modules/security-group" # Local module
# Or: source = "github.com/my-org/terraform-aws-sg?ref=v1.2.0"
# Or: source = "app.terraform.io/my-org/security-group/aws"
name = "web-tier"
vpc_id = module.vpc.vpc_id
ingress_rules = [
{ protocol = "tcp", from_port = 80, to_port = 80, cidr_blocks = ["0.0.0.0/0"] },
{ protocol = "tcp", from_port = 443, to_port = 443, cidr_blocks = ["0.0.0.0/0"] },
]
}
module "alb" {
source = "terraform-aws-modules/alb/aws"
version = "~> 9.0"
name = "web-alb"
vpc_id = module.vpc.vpc_id
subnets = module.vpc.public_subnets
security_groups = [module.security_group.security_group_id]
}
Complete Module Example: AWS Security Group
# modules/security-group/versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
# modules/security-group/variables.tf
variable "name" {
description = "Name prefix for the security group"
type = string
validation {
condition = length(var.name) <= 64
error_message = "Security group name must be 64 characters or fewer."
}
}
variable "description" {
description = "Security group description"
type = string
default = "Managed by Terraform"
}
variable "vpc_id" {
description = "VPC ID where the security group will be created"
type = string
}
variable "ingress_rules" {
description = "List of ingress rules"
type = list(object({
description = optional(string, "")
from_port = number
to_port = number
protocol = string
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
self = optional(bool, false)
}))
default = []
validation {
condition = alltrue([
for rule in var.ingress_rules :
rule.from_port >= 0 && rule.from_port <= 65535
])
error_message = "Ingress from_port must be between 0 and 65535."
}
}
variable "egress_rules" {
description = "List of egress rules"
type = list(object({
description = optional(string, "")
from_port = number
to_port = number
protocol = string
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
prefix_list_ids = optional(list(string), [])
self = optional(bool, false)
}))
default = [
{
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
]
}
variable "tags" {
description = "Tags to apply to the security group"
type = map(string)
default = {}
}
variable "revoke_rules_on_delete" {
description = "Revoke all rules when security group is deleted"
type = bool
default = false
}
variable "create_timeout" {
description = "Timeout for creating the security group"
type = string
default = "10m"
}
# modules/security-group/main.tf
locals {
# Normalize ingress rules for for_each
ingress_rules = {
for idx, rule in var.ingress_rules : "rule-${idx}" => rule
}
egress_rules = {
for idx, rule in var.egress_rules : "rule-${idx}" => rule
}
}
resource "aws_security_group" "this" {
name_prefix = "${var.name}-"
description = var.description
vpc_id = var.vpc_id
revoke_rules_on_delete = var.revoke_rules_on_delete
tags = merge(var.tags, {
Name = var.name
})
lifecycle {
create_before_destroy = true
}
timeouts {
create = var.create_timeout
delete = var.create_timeout
}
}
resource "aws_vpc_security_group_ingress_rule" "this" {
for_each = local.ingress_rules
security_group_id = aws_security_group.this.id
description = each.value.description
from_port = each.value.from_port
to_port = each.value.to_port
ip_protocol = each.value.protocol
cidr_ipv4 = length(each.value.cidr_blocks) > 0 ? each.value.cidr_blocks[0] : null
cidr_ipv6 = length(each.value.ipv6_cidr_blocks) > 0 ? each.value.ipv6_cidr_blocks[0] : null
referenced_security_group_id = length(each.value.security_groups) > 0 ? each.value.security_groups[0] : null
tags = merge(var.tags, {
Name = "${var.name}-ingress-${each.key}"
})
}
resource "aws_vpc_security_group_egress_rule" "this" {
for_each = local.egress_rules
security_group_id = aws_security_group.this.id
description = each.value.description
from_port = each.value.from_port
to_port = each.value.to_port
ip_protocol = each.value.protocol
cidr_ipv4 = length(each.value.cidr_blocks) > 0 ? each.value.cidr_blocks[0] : null
cidr_ipv6 = length(each.value.ipv6_cidr_blocks) > 0 ? each.value.ipv6_cidr_blocks[0] : null
referenced_security_group_id = length(each.value.security_groups) > 0 ? each.value.security_groups[0] : null
prefix_list_id = length(each.value.prefix_list_ids) > 0 ? each.value.prefix_list_ids[0] : null
tags = merge(var.tags, {
Name = "${var.name}-egress-${each.key}"
})
}
# modules/security-group/outputs.tf
output "security_group_id" {
description = "ID of the security group"
value = aws_security_group.this.id
}
output "security_group_arn" {
description = "ARN of the security group"
value = aws_security_group.this.arn
}
output "security_group_name" {
description = "Name of the security group"
value = aws_security_group.this.name
}
output "security_group_vpc_id" {
description = "VPC ID of the security group"
value = aws_security_group.this.vpc_id
}
output "ingress_rule_ids" {
description = "IDs of the ingress rules"
value = [for rule in aws_vpc_security_group_ingress_rule.this : rule.id]
}
output "egress_rule_ids" {
description = "IDs of the egress rules"
value = [for rule in aws_vpc_security_group_egress_rule.this : rule.id]
}
Using the Module
module "web_security_group" {
source = "./modules/security-group"
name = "web-tier"
description = "Security group for web tier"
vpc_id = module.vpc.vpc_id
ingress_rules = [
{
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
description = "HTTPS from anywhere"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
description = "App port from ALB"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [module.alb.security_group_id]
},
]
tags = {
Environment = "production"
Team = "platform"
}
}
Module Composition Patterns
| Pattern | Description | Use Case |
|---|---|---|
| Wrapper Module | Wraps a public module with organization-specific defaults | Apply corporate tagging, security baselines |
| Service Module | Combines multiple modules for a complete service | "Web service" = VPC + ALB + ECS + RDS |
| Environment Module | Instantiates service modules for a specific environment | Production environment configuration |
| Singleton Module | One instance per account/region | IAM roles, DNS zones, VPC peering |
# Wrapper module example: corporate-vpc
# Enforces tagging, enables flow logs, sets DNS options
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = var.name
cidr = var.cidr
azs = var.azs
private_subnets = var.private_subnets
public_subnets = var.public_subnets
# Enforce corporate settings
enable_dns_hostnames = true
enable_dns_support = true
enable_nat_gateway = true
single_nat_gateway = var.environment != "prod"
# VPC Flow Logs (enforced)
enable_flow_log = true
create_flow_log_cloudwatch_iam_role = true
create_flow_log_cloudwatch_log_group = true
flow_log_max_aggregation_interval = 60
tags = merge(var.required_tags, {
Environment = var.environment
})
}
Module Versioning Strategies
| Source Type | Version Pinning | Best For |
|---|---|---|
| Private Registry (TFE) | version = "~> 1.2.0" | Production use, team collaboration |
| GitHub | ref = "v1.2.0" or ref = "main" | Development, rapid iteration |
| S3/HTTP | Path versioning /modules/v1.2.0/ | Air-gapped environments |
| Local path | Git submodules or monorepo | Module development, CI testing |
Version Pinning Rule: Never use floating references (e.g.,
ref = "main" or no version) in production. A module update should be a deliberate, reviewed change. Use exact versions or pessimistic version constraint operators (~> for minor, = for exact).
Module Testing with Terratest
Terratest is a Go library for testing Terraform modules with real infrastructure:
// tests/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()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../examples/complete",
Vars: map[string]interface{}{
"name": "test-sg",
"ingress_rules": []map[string]interface{}{
{
"description": "HTTP",
"from_port": 80,
"to_port": 80,
"protocol": "tcp",
"cidr_blocks": []string{"0.0.0.0/0"},
},
},
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Validate outputs
sgID := terraform.Output(t, terraformOptions, "security_group_id")
assert.NotEmpty(t, sgID)
assert.Contains(t, sgID, "sg-")
sgName := terraform.Output(t, terraformOptions, "security_group_name")
assert.Equal(t, "test-sg", sgName)
}
// Run: go test -v -timeout 30m ./tests/
Catalog Methodology for Module Discovery
For organizations with 50+ modules, a discovery system is essential. Options include:
| Approach | Tooling | Effort |
|---|---|---|
| TFE Module Registry | Native TFE/Cloud | Low; built-in search and versioning |
| Backstage.io | Spotify Backstage | Medium; requires setup but powerful |
| GitHub Topics | GitHub search with topic tags | Low; topic:terraform-module org:my-org |
| Internal Wiki | Confluence, Notion, or this wiki | Medium; manual curation required |