Terraform Modules and Best Practices
Terraform modules enable infrastructure reuse and abstraction by bundling related resources into logical, self-contained units. Modules allow you to create composable infrastructure components, share code across projects, leverage the Terraform Registry, maintain consistent standards, and scale infrastructure management. This guide covers module structure, variable design, output declarations, registry publishing, versioning strategies, module composition patterns, and testing approaches.
Table of Contents
- Understanding Terraform Modules
- Module Directory Structure
- Variables and Inputs
- Outputs and Data Flowing Out
- Module Composition Patterns
- Publishing to Terraform Registry
- Versioning Strategies
- Module Testing
- Best Practices
- Conclusion
Understanding Terraform Modules
A Terraform module is a directory containing Terraform configuration files that define a set of resources. Modules provide abstraction, reusability, and encapsulation of infrastructure components. The simplest module is any directory with .tf files, but well-designed modules follow consistent patterns.
Modules serve several purposes:
Code Reuse: Create once, use many times across projects and environments Abstraction: Hide complexity behind simple, well-defined interfaces Consistency: Enforce standards and best practices across infrastructure Maintainability: Update infrastructure patterns in one place Composability: Combine modules to create complex infrastructure
Modules are invoked using the module block in configuration:
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
environment = var.environment
}
Module Directory Structure
A well-organized module follows standard conventions:
my-module/
├── main.tf
├── variables.tf
├── outputs.tf
├── locals.tf
├── README.md
├── versions.tf
├── terraform.tfvars.example
└── examples/
├── basic/
│ ├── main.tf
│ └── terraform.tfvars
└── advanced/
├── main.tf
└── terraform.tfvars
main.tf: Contains the primary resource definitions. For complex modules, split into logical files:
# main.tf - Create VPC and subnets
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = var.enable_dns_support
tags = merge(
var.tags,
{
Name = "${var.name}-vpc"
}
)
}
resource "aws_subnet" "public" {
for_each = var.public_subnets
vpc_id = aws_vpc.this.id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
map_public_ip_on_launch = true
tags = merge(
var.tags,
{
Name = "${var.name}-public-${each.key}"
Type = "public"
}
)
}
variables.tf: All input variables for the module:
# variables.tf
variable "name" {
type = string
description = "Name prefix for all resources"
nullable = false
validation {
condition = can(regex("^[a-z0-9-]+$", var.name))
error_message = "Name must contain only lowercase letters, numbers, and hyphens."
}
}
variable "cidr_block" {
type = string
description = "CIDR block for VPC"
nullable = false
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "public_subnets" {
type = map(object({
cidr_block = string
availability_zone = string
}))
description = "Map of public subnet configurations"
default = {}
}
variable "tags" {
type = map(string)
description = "Tags to apply to all resources"
default = {}
}
outputs.tf: Expose relevant data from the module:
# outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
description = "VPC ID"
}
output "vpc_cidr_block" {
value = aws_vpc.this.cidr_block
description = "VPC CIDR block"
}
output "public_subnet_ids" {
value = { for name, subnet in aws_subnet.public : name => subnet.id }
description = "Map of public subnet IDs"
}
output "public_route_table_id" {
value = aws_route_table.public.id
description = "Public route table ID"
}
versions.tf: Specify provider versions and required Terraform version:
# versions.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
locals.tf: Local values computed from variables:
# locals.tf
locals {
common_tags = merge(
var.tags,
{
Managed = "Terraform"
Environment = var.environment
Module = "vpc"
}
)
public_subnet_count = length(var.public_subnets)
}
README.md: Comprehensive module documentation. Example structure:
# VPC Module
Creates an AWS VPC with configurable public and private subnets.
## Features
- VPC with configurable CIDR block
- Public subnets with internet gateway
- Private subnets with NAT gateway
- Route tables and security groups
- Automatic DNS configuration
## Usage
```hcl
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "production"
cidr_block = "10.0.0.0/16"
public_subnets = {
"us-east-1a" = { cidr_block = "10.0.1.0/24", availability_zone = "us-east-1a" }
"us-east-1b" = { cidr_block = "10.0.2.0/24", availability_zone = "us-east-1b" }
}
}
Variables
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| name | string | yes | - | Name prefix for resources |
| cidr_block | string | yes | - | VPC CIDR block |
| public_subnets | map(object) | no | {} | Public subnet configs |
Outputs
| Name | Description |
|---|---|
| vpc_id | VPC identifier |
| public_subnet_ids | Map of public subnet IDs |
Examples
See examples/ directory for complete configurations.
## Variables and Inputs
Design variables carefully to create intuitive module interfaces.
**Type Safety**: Always declare variable types explicitly:
```hcl
variable "instance_count" {
type = number
description = "Number of instances to create"
}
variable "environment" {
type = string
description = "Environment name"
}
variable "config" {
type = object({
name = string
cpu = number
memory = number
})
description = "Instance configuration object"
}
variable "tags" {
type = map(string)
description = "Resource tags"
}
variable "allowed_protocols" {
type = list(string)
description = "Allowed protocols"
}
Validation: Validate input values to catch errors early:
variable "instance_type" {
type = string
description = "EC2 instance type"
validation {
condition = can(regex("^[tm][2-3]\\.", var.instance_type))
error_message = "Instance type must be t2, t3, m2, or m3 family."
}
}
variable "environment" {
type = string
description = "Environment name"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "port" {
type = number
description = "Port number"
validation {
condition = var.port >= 1 && var.port <= 65535
error_message = "Port must be between 1 and 65535."
}
}
Sensible Defaults: Provide defaults for optional parameters:
variable "instance_count" {
type = number
description = "Number of instances"
default = 1
validation {
condition = var.instance_count >= 1 && var.instance_count <= 100
error_message = "Instance count must be between 1 and 100."
}
}
variable "enable_monitoring" {
type = bool
description = "Enable CloudWatch monitoring"
default = true
}
variable "tags" {
type = map(string)
description = "Additional resource tags"
default = {}
}
Outputs and Data Flowing Out
Outputs expose relevant information from the module for use by parent modules or outputs in the root configuration.
Basic Outputs:
output "instance_ids" {
value = aws_instance.web[*].id
description = "List of instance IDs"
}
output "load_balancer_dns" {
value = aws_lb.main.dns_name
description = "Load balancer DNS name"
}
output "database_endpoint" {
value = aws_db_instance.main.endpoint
description = "RDS database endpoint"
}
Sensitive Outputs:
output "database_password" {
value = aws_db_instance.main.password
description = "Database master password"
sensitive = true
}
output "api_keys" {
value = {
for key, secret in aws_secretsmanager_secret.api_keys : key => secret.arn
}
description = "API key secrets"
sensitive = true
}
Complex Outputs:
output "web_servers" {
value = {
for instance in aws_instance.web : instance.id => {
private_ip = instance.private_ip
public_ip = instance.public_ip
az = instance.availability_zone
}
}
description = "Web server information"
}
output "security_group_rules" {
value = {
for rule in aws_security_group_rule.main : rule.id => {
protocol = rule.protocol
from_port = rule.from_port
to_port = rule.to_port
cidr_blocks = rule.cidr_blocks
}
}
description = "Security group rules"
}
Module Composition Patterns
Combine modules to build complex infrastructure.
Root Module Pattern:
# main.tf - Compose modules
module "vpc" {
source = "./modules/vpc"
name = var.project_name
cidr_block = var.vpc_cidr
environment = var.environment
tags = var.tags
}
module "security_groups" {
source = "./modules/security"
vpc_id = module.vpc.vpc_id
environment = var.environment
tags = var.tags
depends_on = [module.vpc]
}
module "database" {
source = "./modules/rds"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
security_group_id = module.security_groups.database_sg_id
db_name = var.database_name
db_password = var.database_password
depends_on = [module.security_groups]
}
module "web_servers" {
source = "./modules/ec2"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnet_ids
security_group_id = module.security_groups.web_sg_id
instance_count = var.instance_count
instance_type = var.instance_type
depends_on = [module.security_groups]
}
# Root outputs
output "vpc_id" {
value = module.vpc.vpc_id
}
output "web_server_ips" {
value = module.web_servers.public_ips
}
output "database_endpoint" {
value = module.database.endpoint
}
Module Nesting Pattern:
# modules/platform/main.tf - Compose lower-level modules
module "networking" {
source = "../networking"
vpc_config = var.vpc_config
}
module "compute" {
source = "../compute"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.subnet_ids
instance_config = var.instance_config
}
module "storage" {
source = "../storage"
environment = var.environment
backup_config = var.backup_config
}
output "platform_id" {
value = {
vpc_id = module.networking.vpc_id
compute_ids = module.compute.instance_ids
storage_bucket = module.storage.bucket_id
}
}
Publishing to Terraform Registry
Share modules on the Terraform Registry for community use.
Prerequisites:
# GitHub repository structure
my-org/terraform-provider-resource-type/
# Example
my-org/terraform-aws-vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
├── examples/
└── tests/
Publishing Steps:
# 1. Create GitHub repository
# my-org/terraform-aws-vpc
# (must follow naming convention: terraform-PROVIDER-NAME)
# 2. Add proper versioning
git tag v1.0.0
git push origin v1.0.0
# 3. Add required files
# - README.md with usage examples
# - versions.tf with provider requirements
# - Well-documented variables and outputs
# 4. Sign up for Terraform Registry
# https://app.terraform.io
# Sign in with GitHub
# 5. Navigate to Registry > Modules > Publish
# Select GitHub repository
# Registry auto-publishes on release
# 6. Verify publishing
# Module available at:
# registry.terraform.io/my-org/vpc/aws
Using Registry Modules:
module "vpc" {
source = "my-org/vpc/aws"
version = "~> 1.0"
cidr_block = "10.0.0.0/16"
environment = "production"
}
# Using version constraints
# ~> 1.0 : versions >= 1.0.0 and < 2.0.0
# >= 1.0 : any version 1.0.0 or greater
# 1.0.0 : exact version match
# * : any version
Versioning Strategies
Use semantic versioning to communicate module changes:
MAJOR.MINOR.PATCH
v1.0.0
├── MAJOR: Breaking changes (1 = initial stable release)
├── MINOR: New features, backward compatible
└── PATCH: Bug fixes, no new features
Examples:
v1.0.0 → v1.1.0 (new output, backward compatible)
v1.1.0 → v2.0.0 (renamed variable, breaking)
v1.0.0 → v1.0.1 (fix typo in default)
Version Constraints:
# Exact version
version = "1.0.0"
# Minimum version
version = ">= 1.0.0"
# Range
version = ">= 1.0.0, < 2.0.0"
# Compatible releases
version = "~> 1.0" # >= 1.0, < 2.0
version = "~> 1.0.0" # >= 1.0.0, < 1.1.0
# Recommended for modules
version = ">= 1.0, < 2.0"
Module Testing
Test modules to ensure reliability.
terraform validate:
# Validate syntax
cd modules/vpc
terraform validate
# Check variable types and references
terraform fmt:
# Format code consistently
terraform fmt -recursive .
# Check formatting
terraform fmt -check -recursive .
Manual Testing:
# Test with example
cd examples/basic
terraform init -upgrade
terraform plan
terraform apply
terraform destroy
Automated Testing with Terratest:
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVPCModule(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../examples/basic",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
subnetIds := terraform.OutputMap(t, terraformOptions, "subnet_ids")
assert.NotEmpty(t, subnetIds)
}
Best Practices
Immutability: Once published, don't modify version tags:
# Wrong - don't do this
git tag v1.0.0
# ... make changes ...
git tag -d v1.0.0
git tag v1.0.0
# Right - use new version
git tag v1.0.1
Documentation: Keep README.md up-to-date with all inputs/outputs:
# Usage Example
```hcl
module "vpc" {
source = "my-org/vpc/aws"
version = "~> 1.0"
cidr_block = "10.0.0.0/16"
environment = "prod"
enable_nat = true
}
Inputs
| Name | Type | Required | Description |
|---|---|---|---|
| cidr_block | string | yes | VPC CIDR block |
Outputs
| Name | Description |
|---|---|
| vpc_id | VPC ID |
**Backward Compatibility**: Maintain compatibility when possible:
```hcl
# Wrong - breaking change
variable "environment_name" {
# renamed from "environment"
}
# Right - backward compatible
variable "environment" {
# keep original variable
}
variable "environment_name" {
# add new variable with default from old one
default = var.environment
}
Meaningful Outputs: Only expose necessary values:
# Too many outputs
output "all_tags" {
value = local.all_tags # internal detail
}
# Right outputs
output "vpc_id" {
value = aws_vpc.this.id
}
output "subnet_ids" {
value = aws_subnet.this[*].id
}
Conclusion
Terraform modules are fundamental to scalable, maintainable infrastructure-as-code. By following consistent patterns, designing clear interfaces with well-chosen variables and outputs, leveraging the Terraform Registry for sharing, using semantic versioning, and thoroughly testing, you create reusable infrastructure components that scale across teams and projects. Well-designed modules accelerate infrastructure development while maintaining consistency and reducing errors.


