Loading learning content...
Every software engineer knows the DRY principle—Don't Repeat Yourself. Yet organizations routinely violate DRY in their infrastructure code. Teams copy-paste VPC configurations, duplicate security group definitions, and maintain n slightly-different versions of the same ECS cluster setup.
The cost is substantial: when a security vulnerability requires updating the base AMI across all EC2 instances, teams scramble through dozens of configurations making identical changes. When the networking team wants to enforce new standards, they have to coordinate changes across every consumer.
Terraform modules solve this problem. A module is a reusable container for infrastructure that can be configured, versioned, and shared—just like a library in application code. With modules, you define your VPC pattern once, your ECS cluster once, your RDS database once, and then instantiate them with different parameters.
Mastering modules transforms you from someone who writes Terraform into someone who designs infrastructure platforms.
By the end of this page, you will understand module architecture, how to design clear and flexible module interfaces with variables and outputs, composition patterns for complex infrastructure, versioning strategies, the Terraform Registry, and organizational patterns for internal module libraries.
A module in Terraform is simply a directory containing .tf files. Every Terraform configuration is technically a module—the configuration you run terraform apply on is called the root module. When one module calls another, it's called a child module.
Modules encapsulate:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
# STANDARD MODULE STRUCTURE## modules/# └── vpc/# ├── main.tf # Primary resource definitions# ├── variables.tf # Input variable declarations# ├── outputs.tf # Output value definitions# ├── versions.tf # Terraform and provider version constraints# ├── locals.tf # Local computed values (optional)# ├── data.tf # Data sources (optional)# └── README.md # Documentation (required for published modules)## Additional files for complex modules:# ├── security-groups.tf # Logically grouped resources# ├── subnets.tf # Logically grouped resources# ├── examples/ # Example usage# │ └── complete/# │ └── main.tf# └── tests/ # Module tests (Terraform 1.6+)# └── vpc_test.go # ==============================================================================# CALLING A MODULE# ============================================================================== # Root module (environments/production/main.tf)module "vpc" { # Source can be local path, Git URL, Terraform Registry, S3, etc. source = "../../modules/vpc" # Input variables vpc_name = "production" vpc_cidr = "10.0.0.0/16" availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"] enable_nat_gateway = true single_nat_gateway = false # One NAT per AZ for high availability tags = { Environment = "production" ManagedBy = "terraform" }} # Using module outputsresource "aws_instance" "app" { ami = data.aws_ami.ubuntu.id instance_type = "t3.micro" # Reference module outputs subnet_id = module.vpc.private_subnet_ids[0] vpc_security_group_ids = [module.vpc.default_security_group_id] tags = { Name = "app-server" }} output "vpc_id" { value = module.vpc.vpc_id}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
# ==============================================================================# MODULE: VPC# Creates a production-ready VPC with public and private subnets# ============================================================================== resource "aws_vpc" "this" { cidr_block = var.vpc_cidr enable_dns_hostnames = var.enable_dns_hostnames enable_dns_support = var.enable_dns_support tags = merge( var.tags, { Name = var.vpc_name } )} # ==============================================================================# PUBLIC SUBNETS# ============================================================================== resource "aws_subnet" "public" { count = length(var.availability_zones) vpc_id = aws_vpc.this.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) availability_zone = var.availability_zones[count.index] map_public_ip_on_launch = true tags = merge( var.tags, { Name = "${var.vpc_name}-public-${var.availability_zones[count.index]}" Tier = "public" } )} # ==============================================================================# PRIVATE SUBNETS# ============================================================================== resource "aws_subnet" "private" { count = length(var.availability_zones) vpc_id = aws_vpc.this.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10) availability_zone = var.availability_zones[count.index] tags = merge( var.tags, { Name = "${var.vpc_name}-private-${var.availability_zones[count.index]}" Tier = "private" } )} # ==============================================================================# INTERNET GATEWAY# ============================================================================== resource "aws_internet_gateway" "this" { count = var.create_igw ? 1 : 0 vpc_id = aws_vpc.this.id tags = merge( var.tags, { Name = "${var.vpc_name}-igw" } )} # ==============================================================================# NAT GATEWAY# ============================================================================== resource "aws_eip" "nat" { count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.availability_zones)) : 0 domain = "vpc" tags = merge( var.tags, { Name = "${var.vpc_name}-nat-eip-${count.index}" } ) depends_on = [aws_internet_gateway.this]} resource "aws_nat_gateway" "this" { count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.availability_zones)) : 0 allocation_id = aws_eip.nat[count.index].id subnet_id = aws_subnet.public[count.index].id tags = merge( var.tags, { Name = "${var.vpc_name}-nat-${count.index}" } ) depends_on = [aws_internet_gateway.this]} # ==============================================================================# ROUTE TABLES# ============================================================================== resource "aws_route_table" "public" { vpc_id = aws_vpc.this.id tags = merge( var.tags, { Name = "${var.vpc_name}-public-rt" } )} resource "aws_route" "public_internet_gateway" { count = var.create_igw ? 1 : 0 route_table_id = aws_route_table.public.id destination_cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.this[0].id} resource "aws_route_table_association" "public" { count = length(var.availability_zones) subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id} resource "aws_route_table" "private" { count = var.single_nat_gateway ? 1 : length(var.availability_zones) vpc_id = aws_vpc.this.id tags = merge( var.tags, { Name = "${var.vpc_name}-private-rt-${count.index}" } )} resource "aws_route" "private_nat_gateway" { count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.availability_zones)) : 0 route_table_id = aws_route_table.private[count.index].id destination_cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.this[var.single_nat_gateway ? 0 : count.index].id} resource "aws_route_table_association" "private" { count = length(var.availability_zones) subnet_id = aws_subnet.private[count.index].id route_table_id = aws_route_table.private[var.single_nat_gateway ? 0 : count.index].id}The interface of a module—its variables and outputs—determines how usable and flexible the module is. Great module design requires thinking carefully about what to expose, what to hide, and what defaults make sense.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
# ==============================================================================# REQUIRED VARIABLES# ============================================================================== variable "vpc_name" { description = "Name of the VPC, used as a prefix for all resources" type = string validation { condition = can(regex("^[a-z][a-z0-9-]*$", var.vpc_name)) error_message = "VPC name must start with a letter and contain only lowercase letters, numbers, and hyphens." }} variable "vpc_cidr" { description = "CIDR block for the VPC (e.g., 10.0.0.0/16)" type = string validation { condition = can(cidrhost(var.vpc_cidr, 0)) error_message = "vpc_cidr must be a valid CIDR block." } validation { condition = tonumber(split("/", var.vpc_cidr)[1]) <= 20 error_message = "VPC CIDR must be /20 or larger (smaller number)." }} variable "availability_zones" { description = "List of availability zones for subnet creation" type = list(string) validation { condition = length(var.availability_zones) >= 2 error_message = "At least 2 availability zones are required for high availability." }} # ==============================================================================# OPTIONAL VARIABLES WITH SMART DEFAULTS# ============================================================================== variable "enable_dns_hostnames" { description = "Enable DNS hostnames in the VPC" type = bool default = true} variable "enable_dns_support" { description = "Enable DNS support in the VPC" type = bool default = true} variable "create_igw" { description = "Create an Internet Gateway for the VPC" type = bool default = true} variable "enable_nat_gateway" { description = "Create NAT Gateway(s) for private subnets" type = bool default = true} variable "single_nat_gateway" { description = "Use a single NAT Gateway for all AZs (cost savings, reduced availability)" type = bool default = false # Default to high availability} variable "tags" { description = "Tags to apply to all resources" type = map(string) default = {}} # ==============================================================================# ADVANCED CONFIGURATION# ============================================================================== variable "public_subnet_cidrs" { description = "Optional: explicit CIDR blocks for public subnets. If not provided, calculated automatically." type = list(string) default = [] validation { condition = length(var.public_subnet_cidrs) == 0 || alltrue([for cidr in var.public_subnet_cidrs : can(cidrhost(cidr, 0))]) error_message = "All public_subnet_cidrs must be valid CIDR blocks." }} variable "private_subnet_cidrs" { description = "Optional: explicit CIDR blocks for private subnets. If not provided, calculated automatically." type = list(string) default = []} variable "enable_flow_logs" { description = "Enable VPC Flow Logs to CloudWatch" type = bool default = false} variable "flow_log_retention_days" { description = "Number of days to retain VPC Flow Logs" type = number default = 7 validation { condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 0], var.flow_log_retention_days) error_message = "flow_log_retention_days must be a valid CloudWatch log retention value." }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
# ==============================================================================# PRIMARY OUTPUTS - Essential for most use cases# ============================================================================== output "vpc_id" { description = "ID of the created VPC" value = aws_vpc.this.id} output "vpc_arn" { description = "ARN of the created VPC" value = aws_vpc.this.arn} output "vpc_cidr_block" { description = "CIDR block of the VPC" value = aws_vpc.this.cidr_block} # ==============================================================================# SUBNET OUTPUTS# ============================================================================== output "public_subnet_ids" { description = "List of public subnet IDs" value = aws_subnet.public[*].id} output "private_subnet_ids" { description = "List of private subnet IDs" value = aws_subnet.private[*].id} output "public_subnet_cidrs" { description = "List of public subnet CIDR blocks" value = aws_subnet.public[*].cidr_block} output "private_subnet_cidrs" { description = "List of private subnet CIDR blocks" value = aws_subnet.private[*].cidr_block} # ==============================================================================# GATEWAY OUTPUTS# ============================================================================== output "internet_gateway_id" { description = "ID of the Internet Gateway" value = var.create_igw ? aws_internet_gateway.this[0].id : null} output "nat_gateway_ids" { description = "List of NAT Gateway IDs" value = aws_nat_gateway.this[*].id} output "nat_gateway_public_ips" { description = "Public IPs of NAT Gateways (for whitelisting)" value = aws_eip.nat[*].public_ip} # ==============================================================================# ROUTE TABLE OUTPUTS# ============================================================================== output "public_route_table_id" { description = "ID of the public route table" value = aws_route_table.public.id} output "private_route_table_ids" { description = "List of private route table IDs" value = aws_route_table.private[*].id} # ==============================================================================# DEFAULT SECURITY GROUP# ============================================================================== output "default_security_group_id" { description = "ID of the VPC's default security group" value = aws_vpc.this.default_security_group_id} # ==============================================================================# STRUCTURED OUTPUTS (For Complex Consumption)# ============================================================================== output "subnet_map" { description = "Map of subnet information by AZ" value = { for idx, az in var.availability_zones : az => { public_subnet_id = aws_subnet.public[idx].id private_subnet_id = aws_subnet.private[idx].id public_subnet_cidr = aws_subnet.public[idx].cidr_block private_subnet_cidr = aws_subnet.private[idx].cidr_block } }}enable_* for booleans, *_ids for lists of IDs, *_arns for ARNs.Terraform supports multiple sources for modules, each with different use cases and tradeoffs. Understanding when to use each is key to a maintainable module strategy.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
# ==============================================================================# LOCAL MODULES# ==============================================================================# Use for: Modules within the same repository, development, tight coupling module "vpc" { source = "./modules/vpc" # or relative to root: # source = "../../modules/vpc" vpc_name = "development" vpc_cidr = "10.0.0.0/16" availability_zones = ["us-west-2a", "us-west-2b"]} # ==============================================================================# TERRAFORM REGISTRY MODULES# ==============================================================================# Use for: Community/official modules, verified solutions # Public Registry: registry.terraform.iomodule "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.1.0" # ALWAYS pin version! name = "production" cidr = "10.0.0.0/16" azs = ["us-west-2a", "us-west-2b", "us-west-2c"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] enable_nat_gateway = true single_nat_gateway = true} # Private Registry (Terraform Cloud/Enterprise)module "internal_standards" { source = "app.terraform.io/my-org/standards/internal" version = "1.2.0"} # ==============================================================================# GIT MODULES# ==============================================================================# Use for: Private modules, specific commits, internal sharing # GitHub (HTTPS)module "vpc" { source = "github.com/my-org/terraform-modules//vpc?ref=v1.2.0"} # GitHub (SSH - for private repos)module "vpc" { source = "git@github.com:my-org/terraform-modules.git//vpc?ref=v1.2.0"} # GitLabmodule "vpc" { source = "git::https://gitlab.com/my-org/terraform-modules.git//vpc?ref=v1.2.0"} # Generic Gitmodule "vpc" { source = "git::ssh://git@git.example.com/terraform-modules.git//vpc?ref=v1.2.0"} # ==============================================================================# OTHER SOURCES# ============================================================================== # S3 bucketmodule "vpc" { source = "s3::https://s3-us-west-2.amazonaws.com/my-bucket/vpc.zip"} # GCS bucketmodule "vpc" { source = "gcs::https://www.googleapis.com/storage/v1/my-bucket/vpc.zip"} # HTTP URL (public module archives)module "vpc" { source = "https://example.com/vpc-module.zip"} # ==============================================================================# REFERENCING SUBDIRECTORIES# ==============================================================================# Use // to specify a subdirectory within a repository # Root-level modulemodule "vpc" { source = "github.com/my-org/infrastructure//modules/vpc?ref=v1.0.0"} # Nested modulemodule "eks_node_group" { source = "github.com/my-org/infrastructure//modules/kubernetes/eks-node-group?ref=v1.0.0"}| Source Type | Versioning | Access Control | Best For |
|---|---|---|---|
| Local (./path) | None (uses working copy) | Directory permissions | Same-repo modules, development |
| Terraform Registry | Semantic versioning | Public or org-based | Community modules, verified solutions |
| Git (ref=tag) | Git tags/commits | Git auth (SSH/HTTPS) | Private modules, organizational use |
| S3/GCS | Object versioning | IAM policies | Air-gapped environments, controlled distribution |
Never use a module without specifying a version (or ref for Git). Without pinning, terraform init downloads the latest version, which may contain breaking changes. Your working code could break tomorrow with no changes on your part. Always use version = "x.y.z" or ref=v1.2.3.
Real infrastructure is complex. A production application might need a VPC, EKS cluster, RDS database, ElastiCache, S3 buckets, IAM roles, and dozens of other resources. Module composition is the art of combining smaller, focused modules into larger systems.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
# ==============================================================================# COMPOSITION PATTERN: Building a Complete Application Stack# File: environments/production/main.tf# ============================================================================== locals { environment = "production" region = "us-west-2" common_tags = { Environment = local.environment ManagedBy = "terraform" Project = "my-application" CostCenter = "engineering" }} # ==============================================================================# LAYER 1: NETWORKING# ============================================================================== module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.1.0" name = "${local.environment}-vpc" cidr = "10.0.0.0/16" azs = ["${local.region}a", "${local.region}b", "${local.region}c"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] database_subnets = ["10.0.201.0/24", "10.0.202.0/24", "10.0.203.0/24"] enable_nat_gateway = true single_nat_gateway = false # High availability enable_dns_hostnames = true enable_dns_support = true # Enable for EKS enable_vpn_gateway = false # Subnet tags for EKS auto-discovery private_subnet_tags = { "kubernetes.io/role/internal-elb" = "1" } public_subnet_tags = { "kubernetes.io/role/elb" = "1" } tags = local.common_tags} # ==============================================================================# LAYER 2: COMPUTE (EKS)# ============================================================================== module "eks" { source = "terraform-aws-modules/eks/aws" version = "19.16.0" cluster_name = "${local.environment}-cluster" cluster_version = "1.28" vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnets control_plane_subnet_ids = module.vpc.private_subnets cluster_endpoint_public_access = true eks_managed_node_groups = { general = { min_size = 2 max_size = 10 desired_size = 3 instance_types = ["t3.large"] labels = { Environment = local.environment NodeType = "general" } } } tags = local.common_tags} # ==============================================================================# LAYER 3: DATA (RDS)# ============================================================================== module "rds" { source = "./modules/rds-postgres" identifier = "${local.environment}-db" # Networking vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.database_subnets allowed_security_groups = [module.eks.cluster_security_group_id] # Instance configuration instance_class = "db.r6g.large" allocated_storage = 100 engine_version = "15.3" # High availability multi_az = true # Backup backup_retention_period = 30 backup_window = "03:00-04:00" # Security storage_encrypted = true tags = local.common_tags} # ==============================================================================# LAYER 4: CACHING (ElastiCache)# ============================================================================== module "redis" { source = "./modules/elasticache-redis" cluster_id = "${local.environment}-redis" vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnets allowed_security_groups = [module.eks.cluster_security_group_id] node_type = "cache.r6g.large" num_cache_clusters = 3 # Primary + 2 replicas automatic_failover = true tags = local.common_tags} # ==============================================================================# LAYER 5: OUTPUTS FOR APPLICATIONS# ============================================================================== output "eks_cluster_endpoint" { description = "EKS cluster API endpoint" value = module.eks.cluster_endpoint} output "database_endpoint" { description = "RDS endpoint for application configuration" value = module.rds.endpoint sensitive = true} output "redis_endpoint" { description = "Redis primary endpoint" value = module.redis.primary_endpoint}Notice how each layer depends on outputs from previous layers: EKS uses VPC outputs, RDS uses VPC and EKS security group outputs. This explicit dependency chain makes the infrastructure easy to understand and debug. Terraform automatically determines the correct order from these references.
Community modules like those on the Terraform Registry are powerful but generic. Organizations often need to enforce standards: specific security configurations, tagging policies, approved instance types. Wrapper modules add a layer on top of community modules to enforce these standards.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
# ==============================================================================# WRAPPER MODULE: Company-Standard VPC# Wraps terraform-aws-modules/vpc/aws with organizational defaults# ============================================================================== # Enforce organizational tagging policylocals { required_tags = { ManagedBy = "terraform" CostCenter = var.cost_center Owner = var.owner_email Environment = var.environment Compliance = var.compliance_tier } all_tags = merge(local.required_tags, var.additional_tags) # Environment-specific defaults nat_gateway_config = { development = { enabled = true, single = true } staging = { enabled = true, single = true } production = { enabled = true, single = false } }} # Use community module with our standardsmodule "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.1.0" # Pin to tested version name = "${var.environment}-${var.vpc_name}" cidr = var.vpc_cidr azs = var.availability_zones # Standard subnet layout private_subnets = [for i, az in var.availability_zones : cidrsubnet(var.vpc_cidr, 8, i)] public_subnets = [for i, az in var.availability_zones : cidrsubnet(var.vpc_cidr, 8, i + 100)] database_subnets = [for i, az in var.availability_zones : cidrsubnet(var.vpc_cidr, 8, i + 200)] # Standardized NAT configuration enable_nat_gateway = local.nat_gateway_config[var.environment].enabled single_nat_gateway = local.nat_gateway_config[var.environment].single # Security standards enable_dns_hostnames = true enable_dns_support = true # Enable VPC Flow Logs for compliance enable_flow_log = true create_flow_log_cloudwatch_iam_role = true create_flow_log_cloudwatch_log_group = true flow_log_cloudwatch_log_group_retention_in_days = var.compliance_tier == "high" ? 365 : 30 # Tags tags = local.all_tags # Standard subnet tags for service discovery private_subnet_tags = merge(local.all_tags, { Tier = "private" "kubernetes.io/role/internal-elb" = "1" }) public_subnet_tags = merge(local.all_tags, { Tier = "public" "kubernetes.io/role/elb" = "1" }) database_subnet_tags = merge(local.all_tags, { Tier = "database" })} # ==============================================================================# ADDITIONAL STANDARDS: Security Group for default resources# ============================================================================== resource "aws_security_group" "default" { name = "${var.environment}-${var.vpc_name}-default" description = "Default security group with organizational standards" vpc_id = module.vpc.vpc_id # Allow all internal VPC traffic ingress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = [var.vpc_cidr] description = "Allow all internal VPC traffic" } # Allow all outbound egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] description = "Allow all outbound traffic" } tags = merge(local.all_tags, { Name = "${var.environment}-${var.vpc_name}-default-sg" })}12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
# ==============================================================================# REQUIRED VARIABLES (Our minimal interface)# ============================================================================== variable "vpc_name" { description = "Name for the VPC" type = string validation { condition = can(regex("^[a-z][a-z0-9-]{1,20}$", var.vpc_name)) error_message = "VPC name must be 2-21 lowercase alphanumeric characters, starting with a letter." }} variable "environment" { description = "Environment name" type = string validation { condition = contains(["development", "staging", "production"], var.environment) error_message = "Environment must be development, staging, or production." }} variable "cost_center" { description = "Cost center for billing (required by finance)" type = string validation { condition = can(regex("^CC-[0-9]{6}$", var.cost_center)) error_message = "Cost center must be in format CC-123456." }} variable "owner_email" { description = "Email of team/person responsible for this VPC" type = string validation { condition = can(regex("^[a-zA-Z0-9._%+-]+@company\\.com$", var.owner_email)) error_message = "Owner email must be a valid @company.com email." }} # ==============================================================================# OPTIONAL VARIABLES# ============================================================================== variable "vpc_cidr" { description = "CIDR block for VPC" type = string default = "10.0.0.0/16"} variable "availability_zones" { description = "Availability zones" type = list(string) default = ["us-west-2a", "us-west-2b", "us-west-2c"]} variable "compliance_tier" { description = "Compliance tier affecting logging and security settings" type = string default = "standard" validation { condition = contains(["standard", "high", "pci"], var.compliance_tier) error_message = "Compliance tier must be standard, high, or pci." }} variable "additional_tags" { description = "Additional tags to apply (will be merged with required tags)" type = map(string) default = {}}Notice how the wrapper module exposes fewer variables than the underlying community module. This is intentional—by making decisions about NAT gateway configuration, flow logs, and subnet layout, you simplify the interface for developers while ensuring compliance. They don't need to understand every VPC option; they just specify environment and owner.
Professional module development requires testing. Terraform 1.6+ introduces native testing capabilities, and tools like Terratest provide programmatic testing. Testing catches bugs before modules are consumed across the organization.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
# ==============================================================================# TERRAFORM NATIVE TESTS (1.6+)# File: modules/vpc/tests/vpc_test.tftest.hcl# ============================================================================== # Define providers for testingprovider "aws" { region = "us-west-2"} # ==============================================================================# UNIT TESTS (validate planning, no actual resources)# ============================================================================== run "validate_vpc_cidr" { command = plan variables { vpc_name = "test-vpc" vpc_cidr = "10.0.0.0/16" availability_zones = ["us-west-2a", "us-west-2b"] enable_nat_gateway = false } # Assert the VPC will be created with correct CIDR assert { condition = aws_vpc.this.cidr_block == "10.0.0.0/16" error_message = "VPC CIDR block should be 10.0.0.0/16" } # Assert subnet count matches AZ count assert { condition = length(aws_subnet.public) == 2 error_message = "Should create 2 public subnets for 2 AZs" } assert { condition = length(aws_subnet.private) == 2 error_message = "Should create 2 private subnets for 2 AZs" }} run "validate_nat_gateway_disabled" { command = plan variables { vpc_name = "test-vpc" vpc_cidr = "10.0.0.0/16" availability_zones = ["us-west-2a", "us-west-2b"] enable_nat_gateway = false } assert { condition = length(aws_nat_gateway.this) == 0 error_message = "No NAT gateways should be created when disabled" }} run "validate_single_nat_gateway" { command = plan variables { vpc_name = "test-vpc" vpc_cidr = "10.0.0.0/16" availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"] enable_nat_gateway = true single_nat_gateway = true } assert { condition = length(aws_nat_gateway.this) == 1 error_message = "Only 1 NAT gateway should be created with single_nat_gateway=true" }} # ==============================================================================# INTEGRATION TESTS (create real resources)# Run with: terraform test -filter=integration# ============================================================================== run "integration_create_vpc" { command = apply variables { vpc_name = "integration-test-vpc" vpc_cidr = "10.99.0.0/16" # Non-overlapping CIDR for testing availability_zones = ["us-west-2a", "us-west-2b"] enable_nat_gateway = false # Save cost in tests tags = { TestRun = "automated" } } # Verify VPC was created assert { condition = aws_vpc.this.id != "" error_message = "VPC should have a non-empty ID after creation" } # Verify DNS settings assert { condition = aws_vpc.this.enable_dns_hostnames == true error_message = "VPC should have DNS hostnames enabled" } # Verify outputs assert { condition = output.vpc_id != "" error_message = "vpc_id output should be populated" } assert { condition = length(output.public_subnet_ids) == 2 error_message = "Should output 2 public subnet IDs" }}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// ==============================================================================// TERRATEST - Go-based Infrastructure Testing// File: modules/vpc/tests/vpc_test.go// ============================================================================== package test import ( "testing" "fmt" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/gruntwork-io/terratest/modules/aws" "github.com/stretchr/testify/assert") func TestVPCCreation(t *testing.T) { t.Parallel() awsRegion := "us-west-2" vpcName := fmt.Sprintf("terratest-vpc-%s", random.UniqueId()) terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ TerraformDir: "../", // Path to module Vars: map[string]interface{}{ "vpc_name": vpcName, "vpc_cidr": "10.99.0.0/16", "availability_zones": []string{"us-west-2a", "us-west-2b"}, "enable_nat_gateway": false, "tags": map[string]string{ "TestRun": "terratest", }, }, EnvVars: map[string]string{ "AWS_DEFAULT_REGION": awsRegion, }, }) // Clean up resources after test defer terraform.Destroy(t, terraformOptions) // Create the VPC terraform.InitAndApply(t, terraformOptions) // Get outputs vpcID := terraform.Output(t, terraformOptions, "vpc_id") publicSubnetIDs := terraform.OutputList(t, terraformOptions, "public_subnet_ids") privateSubnetIDs := terraform.OutputList(t, terraformOptions, "private_subnet_ids") // Assertions assert.NotEmpty(t, vpcID, "VPC ID should not be empty") assert.Len(t, publicSubnetIDs, 2, "Should create 2 public subnets") assert.Len(t, privateSubnetIDs, 2, "Should create 2 private subnets") // Use AWS SDK to verify actual resources vpc := aws.GetVpcById(t, vpcID, awsRegion) assert.Equal(t, "10.99.0.0/16", *vpc.CidrBlock) assert.True(t, *vpc.EnableDnsHostnames) // Verify subnets are in expected AZs for i, subnetID := range publicSubnetIDs { subnet := aws.GetSubnetById(t, subnetID, awsRegion) expectedAZ := fmt.Sprintf("us-west-2%c", 'a'+i) assert.Equal(t, expectedAZ, *subnet.AvailabilityZone) }}Run integration tests in dedicated test/sandbox AWS accounts, not production. Use non-overlapping CIDR ranges to avoid conflicts. Consider cost—only run integration tests on PR merge, not every commit (unit tests with 'plan' command are free).
Once you've created a module, you need to distribute it. For public modules, the Terraform Registry is the standard. For internal modules, organizations typically use Git repositories with version tags, or private registries via Terraform Cloud/Enterprise.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
# ==============================================================================# TERRAFORM REGISTRY MODULE REQUIREMENTS# ============================================================================== # Repository naming convention (GitHub):# terraform-<PROVIDER>-<NAME># Examples:# terraform-aws-vpc# terraform-azurerm-storage# terraform-google-gke # Required repository structure:terraform-aws-vpc/├── main.tf # Required: Primary resource definitions├── variables.tf # Required: Variable declarations├── outputs.tf # Required: Output definitions├── versions.tf # Required: Version constraints├── README.md # Required: Documentation├── LICENSE # Required: Open source license│├── examples/ # Recommended: Usage examples│ ├── simple/│ │ ├── main.tf│ │ └── README.md│ └── complete/│ ├── main.tf│ └── README.md│├── modules/ # Optional: Submodules│ └── subnets/│ ├── main.tf│ ├── variables.tf│ └── outputs.tf│└── tests/ # Recommended: Tests └── vpc_test.go # ==============================================================================# VERSION TAGGING FOR RELEASES# ============================================================================== # Use semantic versioning with 'v' prefix:# v1.0.0 - Major: Breaking changes# v1.1.0 - Minor: New features, backwards compatible# v1.1.1 - Patch: Bug fixes, backwards compatible # Create a release:$ git tag -a v1.0.0 -m "Initial release"$ git push origin v1.0.0 # GitHub can auto-detect releases for the Terraform Registry| Method | Pros | Cons | Best For |
|---|---|---|---|
| Git Tags | Simple, no extra infrastructure | No discovery, manual version checking | Small teams, simple modules |
| Terraform Cloud Private Registry | Discovery, versioning, access control | Cost, vendor lock-in | Enterprise, compliance needs |
| Self-Hosted Registry (Terrareg) | Full control, no vendor lock-in | Maintenance burden | Large orgs, air-gapped environments |
| Artifact Repository (Artifactory) | Existing infra, good for compiled modules | Complex setup, not Terraform-native | Orgs with existing artifact management |
Modules transform Terraform from a configuration tool into an infrastructure platform. Let's consolidate the key points:
What's Next:
With modules mastered, the final page covers Terraform Workflow—the end-to-end process of using Terraform in professional environments, including CI/CD integration, code review practices, environment management patterns, and operational best practices.
You now understand how to create, compose, test, and distribute Terraform modules. This knowledge positions you to build infrastructure platforms—not just configurations—enabling your organization to scale infrastructure management efficiently.