Terraform Modules: How I Stopped Copy-Pasting Infrastructure
Practical guide to Terraform modules - when to create them, how to structure them, and patterns that work across teams.
I used to copy-paste Terraform code between projects. It worked until I had 15 projects with slightly different versions of the same EC2 setup. When a security fix was needed, I had to update all 15. Some got missed. Production had vulnerabilities.
Modules solved this. One source of truth, used everywhere.
When to Create a Module
Not everything needs to be a module. I create one when:
- I'm copying the same resources across multiple projects
- A pattern has stabilized and I'm confident it won't change drastically
- Multiple resources work together as a logical unit
I don't create modules for:
- Single resources with no associated complexity
- Patterns I'm still experimenting with
- One-off infrastructure
Starting with modules too early leads to premature abstraction. I wait until I've copy-pasted something at least twice before extracting it.
Module Structure
Every module I write follows this structure:
modules/web-server/
├── main.tf # Resources
├── variables.tf # Inputs
├── outputs.tf # Outputs
├── versions.tf # Provider constraints
└── README.md # Usage documentation
Variables (The Module's API)
Variables define what consumers can configure. I always include descriptions and use validation where it matters:
variable "name" {
description = "Name for the instance and related resources"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
validation {
condition = can(regex("^t3\\.", var.instance_type))
error_message = "Must be a t3 instance type."
}
}
variable "subnet_id" {
description = "Subnet ID for the instance"
type = string
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
Good defaults reduce boilerplate for consumers. Required variables should be truly required.
Outputs (What Consumers Need)
Outputs expose values that other resources might need:
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.this.id
}
output "private_ip" {
description = "Private IP address"
value = aws_instance.this.private_ip
}
output "security_group_id" {
description = "ID of the security group"
value = aws_security_group.this.id
}
I output anything that might be referenced downstream. It's easier to add outputs upfront than to add them later when someone needs them.
Calling Modules
From Local Path
module "web_server" {
source = "./modules/web-server"
name = "api-server"
instance_type = "t3.small"
subnet_id = module.vpc.private_subnet_ids[0]
vpc_id = module.vpc.vpc_id
tags = {
Environment = "prod"
Team = "platform"
}
}
From Git
module "web_server" {
source = "git::https://github.com/myorg/terraform-modules.git//web-server?ref=v1.2.0"
name = "api-server"
subnet_id = var.subnet_id
vpc_id = var.vpc_id
}
Always pin versions in production. I use Git tags for versioning private modules.
From Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
# ...
}
The community has already built excellent modules for common patterns. I use them when they fit and wrap them when I need to enforce company standards.
Patterns That Work
Conditional Resources
variable "create_dns_record" {
type = bool
default = false
}
resource "aws_route53_record" "this" {
count = var.create_dns_record ? 1 : 0
# ...
}
Multiple Instances with for_each
variable "servers" {
type = map(object({
instance_type = string
subnet_id = string
}))
}
module "servers" {
for_each = var.servers
source = "./modules/web-server"
name = each.key
instance_type = each.value.instance_type
subnet_id = each.value.subnet_id
}
Wrapper Modules
When using community modules, I create thin wrappers that enforce company standards:
# modules/company-vpc/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
# Company standards enforced
enable_dns_hostnames = true
enable_flow_log = true # Required by security
tags = merge(var.tags, {
ManagedBy = "Terraform"
})
}
This way teams use module "vpc" { source = "./modules/company-vpc" } and automatically get compliant infrastructure.
Versioning Strategy
For private modules, I use Git tags:
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
Then reference with ?ref=v1.0.0 in the source URL.
Version constraints I use:
| Constraint | When I Use It |
|---|---|
= 1.0.0 | Production - exact version |
~> 1.0 | When I trust minor updates |
>= 1.0.0, < 2.0.0 | When I need flexibility with a ceiling |
Never use >= 1.0.0 without an upper bound in production. A major version bump could break everything.
Mistakes I've Made
Creating modules too early. I built an elaborate module for a pattern I used once. It was wasted effort. Now I wait for repetition.
Too many required variables. My first modules had 20+ required inputs. Nobody wanted to use them. Good defaults make modules usable.
Not outputting enough. I'd create a module, then someone needed an attribute I didn't output. Adding outputs later means touching every consumer. Output generously upfront.
Tight coupling. Modules that assume too much about their environment are hard to reuse. Keep them focused on one thing.
Testing Modules
At minimum, I validate every module:
cd modules/web-server
terraform init
terraform validate
For critical modules, I maintain working examples:
modules/web-server/
└── examples/
└── basic/
└── main.tf
These examples serve as documentation and can be used for integration testing.
Key Takeaways
- Wait for repetition - Don't create modules until you've copy-pasted twice
- Good defaults reduce friction - Minimize required variables
- Output generously - Anything downstream might need becomes an output
- Pin versions in production - Use exact versions or tight constraints
- Wrap community modules - Enforce company standards through thin wrappers
- Keep modules focused - One logical unit per module
- Document with examples - Working examples are better than prose
Written by Bar Tsveker
Senior CloudOps Engineer specializing in AWS, Terraform, and infrastructure automation.
Thanks for reading! Have questions or feedback?