Modules & Patterns
Terraform modules are reusable packages of configuration. Instead of copying and pasting the same VPC setup, database configuration, or compute instance block across every project, you write it once as a module and call it wherever you need it. Modules are the primary mechanism for code reuse, abstraction, and consistency in Terraform.
What a Module Is
Every Terraform configuration is technically a module. The directory you run terraform apply in is the root module. When that root module calls another directory of Terraform files, it is using a child module.
Project structure:
infrastructure/
production/
main.tf <- Root module
variables.tf
outputs.tf
modules/
vpc/
main.tf <- Child module
variables.tf
outputs.tf
rds/
main.tf <- Child module
variables.tf
outputs.tf
The root module calls child modules:
# infrastructure/production/main.tf
module "vpc" {
source = "../modules/vpc"
environment = "production"
cidr_block = "10.0.0.0/16"
}
module "database" {
source = "../modules/rds"
environment = "production"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.r6g.large"
}
Module Structure
A well-structured module has three files at minimum:
modules/vpc/
main.tf Resources and data sources
variables.tf Input variables (the module's API)
outputs.tf Output values (what the module exposes)
README.md Usage documentation (optional but recommended)
versions.tf Provider and Terraform version constraints
variables.tf: The Module's Input API
# modules/vpc/variables.tf
variable "environment" {
description = "Environment name (e.g., production, staging)"
type = string
}
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "enable_nat_gateway" {
description = "Whether to create a NAT gateway for private subnets"
type = bool
default = true
}
main.tf: The Resources
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
}
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.environment}-public-${var.availability_zones[count.index]}"
Environment = var.environment
}
}
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index + length(var.availability_zones))
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.environment}-private-${var.availability_zones[count.index]}"
Environment = var.environment
}
}
outputs.tf: The Module's Output API
# modules/vpc/outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
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
}
Calling Modules
Local Modules
module "vpc" {
source = "../modules/vpc"
environment = "production"
cidr_block = "10.0.0.0/16"
}
Remote Modules from the Terraform Registry
The Terraform Registry hosts community and official modules. They follow a standardized structure and are versioned.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = "production-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
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
}
Git Repositories
module "vpc" {
source = "git::https://github.com/myorg/terraform-modules.git//vpc?ref=v1.2.0"
environment = "production"
}
Always pin module versions. Without a version constraint, terraform init pulls the latest, which may contain breaking changes.
Composition: Root Module Calls Child Modules
A well-designed infrastructure uses the root module as an orchestrator that wires child modules together:
# infrastructure/production/main.tf
module "vpc" {
source = "../modules/vpc"
environment = var.environment
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
module "database" {
source = "../modules/rds"
environment = var.environment
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.r6g.large"
engine_version = "16.1"
}
module "cache" {
source = "../modules/elasticache"
environment = var.environment
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
node_type = "cache.t4g.medium"
}
module "ecs" {
source = "../modules/ecs"
environment = var.environment
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
database_url = module.database.connection_string
redis_url = module.cache.connection_string
}
The root module contains no resource blocks -- it only calls modules and wires their outputs to each other's inputs. This makes the high-level architecture readable at a glance.
The Module Hierarchy
Root module (production/main.tf)
├── module "vpc" Networking: VPC, subnets, NAT, route tables
├── module "database" Data: RDS instance, security groups, parameter groups
├── module "cache" Data: ElastiCache, security groups
├── module "ecs" Compute: ECS cluster, task definitions, services
├── module "monitoring" Observability: CloudWatch dashboards, alarms
└── module "dns" Networking: Route53 records, ACM certificates
Each module encapsulates one concern. Modules communicate through explicit inputs (variables) and outputs. There are no hidden dependencies.
Naming Conventions
Consistent naming prevents confusion across teams:
Module naming:
modules/vpc/ Not: modules/network/, modules/networking/
modules/rds/ Not: modules/database/, modules/db/
modules/ecs/ Not: modules/compute/, modules/containers/
Resource naming within modules:
resource "aws_vpc" "main" Not: "this", "default", "vpc"
resource "aws_subnet" "public" Not: "pub_subnet", "subnet_1"
resource "aws_subnet" "private" Not: "priv", "internal"
Variable naming:
variable "environment" Not: "env", "stage"
variable "instance_type" Not: "type", "size"
variable "enable_nat_gateway" Not: "nat", "create_nat"
Use snake_case for everything. Prefix boolean variables with enable_ or create_. Use descriptive names even if they are longer.
When to Modularize
Not everything needs to be a module. Premature modularization creates indirection without benefit.
Modularize When
- The same pattern appears in two or more places (DRY principle)
- A group of resources always appears together (VPC + subnets + route tables)
- You want to enforce standards across teams (the module encodes best practices)
- The configuration is large enough that reading it as one flat file is painful
Keep It Flat When
- You have one environment with no plans for more
- The configuration is small (under 200 lines)
- The team is small and everyone understands the full config
- Modularizing would create modules with a single resource (over-abstraction)
Too flat:
main.tf with 500 lines, 30 resources, VPC + database + compute + monitoring
-> Hard to read, hard to review, changes to monitoring affect database plan
Too modular:
modules/security_group/ (wraps a single aws_security_group resource)
modules/iam_role/ (wraps a single aws_iam_role resource)
-> Adds indirection without reducing complexity
Right level:
modules/vpc/ (VPC + subnets + NAT + route tables: always together)
modules/rds/ (RDS + security groups + parameter groups: always together)
modules/ecs/ (Cluster + task definitions + services: always together)
Sharing Modules Across Teams
For larger organizations, modules can be published to a private registry or a shared Git repository:
Option 1: Terraform Cloud/Enterprise private registry
- Versioned modules
- Discovery via UI
- Access control per workspace
Option 2: Git monorepo
terraform-modules/
modules/
vpc/
rds/
ecs/
- Referenced via Git URL with ref tag
- Versioned via Git tags
Option 3: Git per module
terraform-module-vpc (one repo per module)
terraform-module-rds
terraform-module-ecs
- More overhead, but cleaner versioning
Regardless of the approach, every shared module must:
- Have clear documentation (what it does, what variables it accepts, what it outputs)
- Be versioned with semantic versioning
- Have at least basic validation on input variables
- Be tested (terraform validate at minimum, automated plan/apply tests ideally)
Common Pitfalls
- Premature modularization -- Extracting a module for a pattern you use once adds complexity without benefit. Wait until you have actual duplication.
- Tightly coupled modules -- Modules that directly reference resources in other modules (instead of passing values through variables and outputs) are fragile and hard to reuse.
- Unpinned module versions -- Using
source = "../modules/vpc"without a version means any change to the module immediately affects all consumers. Pin versions for production. - Overly complex module interfaces -- A module with 40 input variables is hard to use. If the interface is too complex, the module is doing too much. Split it.
- No documentation -- A module without a README explaining its purpose, required variables, and example usage will be misused or avoided.
- Wrapping single resources -- A module that wraps a single resource without adding logic or defaults is just indirection. Use the resource directly.
Key Takeaways
- Modules are Terraform's mechanism for reuse, abstraction, and consistency
- A module has a clear interface: input variables, resources, and output values
- The root module orchestrates child modules by wiring outputs to inputs
- Pin module versions in production to prevent unexpected changes
- Modularize when patterns repeat or configurations grow too large to read as flat files; keep it flat for small, single-use configurations
- Name things consistently with
snake_case, descriptive names, andenable_prefixes for booleans - Shared modules must be versioned, documented, and validated