
Build Reusable Terraform Modules for AWS
Copying Terraform resources between environments feels fast until a security rule, tag, or naming convention changes. Reusable modules replace that drift with one reviewed implementation and a small, intentional interface. A good module describes an architectural capability—such as a private application network—not a thin wrapper around one AWS resource.
This guide shows how to structure, consume, validate, version, and refactor Terraform modules for AWS without creating a deeply nested system that nobody can safely change.
Root modules and child modules
The Terraform files in the directory where you run terraform plan form the root module. A root module configures providers, chooses environment-specific values, and calls child modules. Child modules define reusable infrastructure and expose inputs and outputs.
HashiCorp recommends using modules to raise the abstraction level and keeping the module tree relatively flat. The official module development guide specifically cautions against excessive modules and thin wrappers that add complexity without creating a meaningful architectural concept.
Choose a clear module boundary
A useful AWS module should own resources that normally change together. Examples include:
- An application network containing a VPC, public and private subnets, route tables, and required endpoints.
- A load-balanced service containing a target group, listener rules, task definition, and service autoscaling.
- An encrypted data bucket containing lifecycle rules, public-access blocking, logging, and an access policy.
A module named aws_s3_bucket_wrapper that only renames the arguments of one resource is usually not worthwhile. Start from how application teams describe the capability, then define the minimum interface they need.
Use the standard file structure
modules/
└── application-network/
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── locals.tf
├── tests/
│ └── basic.tftest.hcl
└── examples/
└── complete/
└── main.tf
Only the root module is technically required, but predictable filenames help tools and reviewers navigate the code. HashiCorp's standard module structure also recommends a README, examples, and nested modules only when they represent optional or complex subcomponents.
Declare provider requirements, not credentials
A reusable child module declares which providers it requires in versions.tf:
terraform {
required_version = ">= 1.9, < 2.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
Configure the AWS provider in the root module, where credentials, region, default tags, and aliases belong:
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
}
Provider configurations are global to the overall configuration and cannot be defined by reusable child modules. Each child module still declares its provider source and compatible versions, as described in HashiCorp's provider guidance.
Design typed, validated inputs
Prefer a small interface with strong types and validation:
variable "name" {
description = "Stable name prefix for network resources."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,30}$", var.name))
error_message = "name must be 3-31 lowercase letters, digits, or hyphens."
}
}
variable "vpc_cidr" {
description = "IPv4 CIDR block for the VPC."
type = string
validation {
condition = can(cidrnetmask(var.vpc_cidr))
error_message = "vpc_cidr must be a valid IPv4 CIDR block."
}
}
variable "availability_zones" {
type = list(string)
validation {
condition = length(var.availability_zones) >= 2
error_message = "Provide at least two availability zones."
}
}
Do not expose every underlying AWS argument. Provide safe defaults for optional behavior and expose only decisions that consumers genuinely need to make.
Build resources from inputs and locals
locals {
common_tags = {
Component = "application-network"
Name = var.name
}
}
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = local.common_tags
}
resource "aws_subnet" "private" {
for_each = toset(var.availability_zones)
vpc_id = aws_vpc.this.id
availability_zone = each.value
cidr_block = cidrsubnet(var.vpc_cidr, 4, index(var.availability_zones, each.value))
tags = merge(local.common_tags, { Tier = "private" })
}
Stable for_each keys are safer than numeric indexes when collections may be reordered. Changing a list position used by count can cause Terraform to reinterpret resource addresses and propose unnecessary replacement.
Expose outputs as a small API
output "vpc_id" {
description = "ID of the application VPC."
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "Private subnet IDs keyed by availability zone."
value = {
for zone, subnet in aws_subnet.private : zone => subnet.id
}
}
Outputs should help other modules compose infrastructure without exposing entire resource objects. Map outputs with stable semantic keys make dependencies easier to read and reduce coupling to internal implementation details.
Call the module from an environment
module "network" {
source = "git::https://github.com/example/terraform-aws-network.git?ref=v2.3.1"
name = "payments-prod"
vpc_cidr = "10.40.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
}
module "service" {
source = "git::https://github.com/example/terraform-aws-service.git?ref=v1.8.0"
vpc_id = module.network.vpc_id
subnet_ids = values(module.network.private_subnet_ids)
}
Pin remote modules to an immutable release tag or registry version. Tracking a moving main branch can change production plans without a deliberate version update.
Test modules before consumers discover mistakes
Run formatting and static validation on every change:
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
terraform test
Add policy or security scanning that matches your organization, and execute a real plan against a disposable account when a module's behavior cannot be proven statically. Examples should be small but complete enough for a user to run.
Refactor without destroying resources
Renaming a resource changes its Terraform address. Record that change with a moved block:
moved {
from = aws_vpc.main
to = aws_vpc.this
}
Terraform then understands that the existing object has a new address instead of planning a delete and recreate. Removing old moved blocks can be a breaking change for consumers skipping versions, so document the supported upgrade path.
Module review checklist
- The module represents an architectural capability, not a renamed resource.
- Inputs are typed, documented, validated, and minimal.
- Providers and credentials are configured by the root module.
- Outputs form a stable interface for composition.
- Remote consumers pin an immutable version.
- Formatting, validation, tests, and example plans run in CI.
- Breaking changes use semantic versions and migration notes.
- Resource renames include
movedblocks.
Reusable Terraform modules are most effective when their interfaces are smaller than their implementations. Keep composition flat, centralize secure defaults, version deliberately, and make safe upgrades part of the module design rather than an afterthought.