Terraform modules have a reputation problem — everyone says to use them, and almost nobody uses them well. As someone who spent years reading through infrastructure codebases ranging from tidy to genuinely alarming, I learned the difference between modules that actually help and modules that are just copy-paste with variables swapped in. Today I’ll share what’s worth building and how to do it without a multi-week refactor project.

What a Module Actually Is (And Isn’t)
A Terraform module is any directory containing .tf files. That means you’re already using modules — your root configuration is a module. The question isn’t whether to use modules, it’s whether your modules are designed intentionally or just accumulated.
A well-designed module has one job, takes the inputs it needs to do that job, and exposes the outputs downstream resources will need. A poorly designed module is a copy-paste of resources from your last project with variables swapped in wherever something needed to change.
The Three Modules Most Teams Need First
VPC module. Every project needs a VPC. The parameters are almost always the same: CIDR blocks, subnet configuration, NAT gateway options, flow logs. Encapsulate this once. Every project after the first should be calling a tested, versioned VPC module rather than rewriting subnet math.
The most common mistake: putting environment-specific values (dev vs prod CIDR blocks) inside the module instead of passing them as variables. Modules should be environment-agnostic. The caller decides the values; the module decides the structure.
ECS service module. An ECS service has a predictable shape: task definition, service, load balancer target group, security group rules, IAM role, CloudWatch log group. Writing this from scratch for each service is the kind of repetition that introduces configuration drift between services. A module enforces consistency across all of them.
Minimum inputs: image URI, CPU/memory, port, desired count, environment variables, secrets ARNs. Minimum outputs: service ARN, task role ARN, security group ID. Everything else either has a sensible default or shouldn’t be in the module at all.
RDS module. Parameter groups, subnet groups, security groups, backup windows, deletion protection — these are the things that get configured wrong when someone’s in a hurry to stand up a database. That’s what makes the RDS module endearing to infrastructure teams — the right configuration is also the easiest path.
Module Versioning: The Part People Skip
Unversioned modules are worse than no modules. If your module source is a relative path or a direct Git reference without a tag, every apply picks up the latest changes — including breaking ones. Use Git tags and pin your module calls:
module "vpc" {
source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=v1.4.0"
...
}
I’m apparently someone who learned this the hard way — a shared module got a breaking change pushed to main on a Monday morning, and three different environments ran plans that afternoon. Tagging releases feels like overhead right up until it isn’t.
The Folder Structure That Scales
terraform-modules/
├── vpc/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── README.md
├── ecs-service/
├── rds/
└── ...
One module per directory. Each module gets a README documenting its inputs, outputs, and an example call. Probably should have led with this, honestly — the README is what determines whether your team uses the module or rewrites it from scratch because they couldn’t figure out how to call it.
Testing Before You Regret It
Terratest or the native terraform test command (available since Terraform 1.6) are both viable. At minimum, write a test that runs terraform init, plan, and apply against a real AWS environment and verifies the outputs match expectations. The setup takes a few hours. The hours it saves catching breaking changes before they hit production: ongoing.
Leave a Reply