Terraform modules examples: patterns, pitfalls and fixes
As your infrastructure grows, copy-pasting Terraform code becomes a maintenance nightmare. Modules let you package reusable infrastructure blocks once and deploy them consistently across projects. In this article, we'll walk through real patterns used in production systems and show you how to avoid the traps that catch teams when they first adopt modular infrastructure.
What a module is — a folder of .tf files with inputs and outputs
At its core, a Terraform module is just a directory containing .tf files. The simplest modules have three key files:
main.tf— the resource definitionsvariables.tf— input variables (the module's parameters)outputs.tf— values you expose back to the caller
Think of modules like functions in programming. You define inputs, do some work, and return outputs. The caller doesn't need to know or care how the work happens inside.
Here's a minimal network module structure:
modules/
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tfInside variables.tf, you declare what the module accepts:
variable "cidr" {
type = string
description = "VPC CIDR block"
}
variable "az_count" {
type = number
description = "Number of availability zones"
default = 2
}
variable "environment" {
type = string
description = "Environment name (dev, staging, prod)"
}In main.tf, you use those variables to create actual resources:
resource "aws_vpc" "main" {
cidr_block = var.cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
}
}
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.environment}-subnet-${count.index + 1}"
}
}Finally, expose what matters via outputs.tf:
output "vpc_id" {
value = aws_vpc.main.id
description = "VPC ID"
}
output "subnet_ids" {
value = aws_subnet.private[*].id
description = "List of private subnet IDs"
}That's a module. Folders, variables, resources, outputs. No magic.
Call a module — pass variables and use outputs
Once you've written a module, calling it is straightforward. In your root Terraform configuration (or another module), use a module block:
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
az_count = 3
environment = "production"
}
module "database" {
source = "./modules/rds"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.subnet_ids
environment = "production"
instance_class = "db.r6i.xlarge"
allocated_storage = 100
}Notice how the database module consumes outputs from the VPC module. That's composition. You build layers: networking, then databases, then services, each module depending on outputs from the layer below.
When you run terraform apply, Terraform parses the module block, reads the source directory, interpolates your variables, and creates all resources inside it. From your perspective, you call a module like you'd call a function — you pass arguments and get results.
In a real project, you'll have a directory structure like this:
production/
├── main.tf # calls modules
├── variables.tf # inputs to the root config
└── terraform.tfvars # actual values (dev, staging, prod differ)
modules/
├── vpc/
├── rds/
├── security_group/
└── iam_role/You can also source modules from remote locations — a Git repository or the Terraform Registry — using:
source = "git::https://github.com/myorg/terraform-modules.git//modules/vpc?ref=v1.2.0"This lets teams share modules across multiple projects without duplicating code.
Registry modules — terraform-aws-modules saves time for common resources
The Terraform Registry (registry.terraform.io) hosts thousands of pre-built modules, maintained by HashiCorp, AWS, and the community. The terraform-aws-modules organization publishes some of the most reliable and battle-tested ones.
For example, instead of writing your own VPC module, you can use the official one:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
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
enable_vpn_gateway = true
enable_dns_hostnames = true
tags = {
Environment = "production"
}
}This single block handles:
- VPC creation
- Public and private subnets across multiple AZs
- NAT gateways for outbound traffic from private subnets
- Internet gateway attachment
- Route table configuration
- VPN gateway (if enabled)
Writing that from scratch would take hours and introduce bugs. Registry modules are tested in production across thousands of deployments. They also evolve — when AWS releases new features, these modules usually add support within weeks.
However, registry modules come with a tradeoff: they're more opinionated and have more inputs than a custom module. Read the module's documentation carefully. Always pin the version using ~> 5.0 or 5.2.1 — never use the latest implicitly, as breaking changes can slip through.
Keep modules focused — one purpose per module
A common mistake is building "god modules" that do everything. Don't create a module called aws_infrastructure that provisions VPCs, databases, load balancers, and IAM roles all at once. That defeats the purpose of modularity.
Instead, follow the single responsibility principle:
vpc— networking onlyrds— database onlyeks— Kubernetes onlyiam_role— IAM roles for a specific servicesecurity_group— firewall rules
Then compose them together in your root configuration:
module "network" {
source = "./modules/vpc"
cidr = var.vpc_cidr
environment = var.environment
}
module "app_sg" {
source = "./modules/security_group"
vpc_id = module.network.vpc_id
name = "app-servers"
environment = var.environment
}
module "database" {
source = "./modules/rds"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
security_group_id = module.app_sg.id
environment = var.environment
}
module "eks_cluster" {
source = "./modules/eks"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
database_endpoint = module.database.endpoint
environment = var.environment
}This approach gives you flexibility. You can swap out the RDS module for DynamoDB without touching networking code. You can test each module independently. You can reuse the VPC module across five different projects without copying it.
Focused modules are also easier to version. When you bump a module to version 2.0 because of a breaking change, you only have to update the projects that actually use it.
Common mistakes
Overly complex variable validation: Don't add validation rules so strict that the module becomes hard to use. If you require 15 specific inputs with complex interdependencies, you've probably built a god module.
Hardcoded values in modules: If you write region = "us-east-1" inside a module instead of accepting it as a variable, your module only works in that region. Always parameterize things that might vary.
Forgetting about remote state: When modules live in a separate Git repo and teams call them from different projects, each project needs its own Terraform state. If you mix state files, terraform destroy becomes terrifying.
Not versioning modules: If you call a module with source = "git::https://github.com/myorg/modules.git//vpc" without a ref, you're pinning to the latest commit on main. Someone changes the module next week, and your production deploy breaks. Always use ?ref=v1.2.3.
Ignoring module outputs: If a module creates an RDS instance but doesn't output the endpoint, the calling code has to hardcode the database hostname or use data sources to look it up. That's brittle. Always expose the outputs your callers will need.
Putting too much logic in locals: Locals are convenient, but if a module has 20 local values doing complex calculations, it's hard to understand and debug. Keep locals simple; use variables for user-facing inputs.
Key takeaways
- A module is a folder of .tf files with inputs (variables), logic (resources), and outputs. It's infrastructure as a function.
- Call modules with a
moduleblock, pass variables, and consume outputs. Compose multiple modules in a root configuration to build complete systems. - Registry modules like
terraform-aws-modulessave time for common patterns, but always pin versions and read documentation. - Keep modules focused: one responsibility per module. This makes them testable, reusable, and versionable.
- Always parameterize configurable values, expose outputs your callers need, and version modules in Git to prevent unexpected changes.
- Use modules as your primary tool for sharing infrastructure code across teams and projects.






Comments
Loading comments…