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

  1. Understanding Terraform Modules
  2. Module Directory Structure
  3. Variables and Inputs
  4. Outputs and Data Flowing Out
  5. Module Composition Patterns
  6. Publishing to Terraform Registry
  7. Versioning Strategies
  8. Module Testing
  9. Best Practices
  10. 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

NameTypeRequiredDefaultDescription
namestringyes-Name prefix for resources
cidr_blockstringyes-VPC CIDR block
public_subnetsmap(object)no{}Public subnet configs

Outputs

NameDescription
vpc_idVPC identifier
public_subnet_idsMap 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

NameTypeRequiredDescription
cidr_blockstringyesVPC CIDR block

Outputs

NameDescription
vpc_idVPC 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.