How to modularise Terraform without overcomplicating it
When I first started writing Terraform for my project, everything lived in one place - a single main.tf with all my resources dumped in. It worked, but it got messy fast. By the time I had VPC, ECR, ACM, NLB, and ECS resources, the file was impossible to navigate and nothing was reusable.
Modules fixed that. Here's how I approached breaking it down.
What is a Terraform module?
A module is just a folder with .tf files. That's it. When you call it from a parent configuration, Terraform treats it as a self-contained unit - you pass in variables, it creates resources and returns outputs.
Every Terraform project already has one module - the root module (your terraform/ directory). Child modules are just subdirectories you reference with a source path.
When to extract a module
Not everything needs to be a module. A good rule of thumb: extract when a group of resources has a clear boundary, can be reasoned about independently, and might be reused.
For my project the split was obvious:
- VPC - networking is completely independent of the application
- ECR - image registry, no dependencies on other resources
- ACM - certificate management, depends only on domain config
- NLB - load balancer, depends on VPC and ACM
- ECS - the application layer, depends on everything else
Each became its own module under terraform/modules/.
The structure
terraform/
- main.tf # root module - calls child modules
- variables.tf # root-level input variables
- outputs.tf # root-level outputs
- terraform.tfvars # variable values (gitignored)
- modules/
- vpc/
- main.tf
- variables.tf
- outputs.tf
- ecr/
- main.tf
- variables.tf
- outputs.tf
- acm/
- main.tf
- variables.tf
- outputs.tf
- nlb/
- main.tf
- variables.tf
- outputs.tf
- ecs/
- main.tf
- variables.tf
- outputs.tf
Every module follows the same three-file pattern: main.tf defines resources, variables.tf defines inputs, outputs.tf exposes values other modules can use.
Calling a module
In the root main.tf, you call a module like this:
module "network" {
source = "./modules/vpc"
vpc_cidr = var.vpc_cidr
availability_zones = var.availability_zones
environment = var.environment
name_prefix = var.name_prefix
}
source points to the folder. Everything else is just passing variable values in.
Passing values between modules
Modules communicate through outputs. If the ECS module needs the VPC ID, the VPC module exposes it as an output, and the root module passes it through:
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
# terraform/main.tf
module "ecs" {
source = "./modules/ecs"
vpc_id = module.network.vpc_id # reference the VPC module's output
...
}
This is the key pattern - modules never reference each other directly. Everything flows through the root.
Variables and naming conventions
Each module defines its own variables. I used name_prefix as a consistent variable across all modules so every resource name follows the same pattern:
# modules/ecs/variables.tf
variable "name_prefix" {
type = string
}
# modules/ecs/main.tf
resource "aws_ecs_cluster" "cluster" {
name = "${var.name_prefix}-cluster"
}
With name_prefix = "headscale", every resource in every module gets a consistent prefix - headscale-cluster, headscale-service, headscale-ecs-sg - making it easy to find resources in the AWS console and understand what belongs to this project.
What I learned
The biggest shift in thinking: modules are about boundaries, not file length. A module shouldn't just be "resources I extracted to clean up main.tf" - it should represent a logical unit that can stand alone. If you need to understand the VPC to understand the ECS config, the boundary is in the wrong place.
The other thing worth knowing early: providers in child modules. If you use a non-HashiCorp provider (like Cloudflare) in a child module, you need to declare it in that module's required_providers block with the correct source. If you don't, Terraform falls back to looking for hashicorp/cloudflare, which doesn't exist, and your terraform init will fail with a confusing error.
The result
Once modularised, adding a new resource to the VPC doesn't require scrolling past ECS task definitions. Reviewing the NLB config is self-contained. And if I ever want to reuse the VPC module in another project, it's already portable.
It's a bit more setup upfront, but the project immediately becomes easier to navigate, easier to review in pull requests, and easier to reason about when something goes wrong.
