Skip to main content

Command Palette

Search for a command to run...

How to modularise Terraform without overcomplicating it

Updated
4 min read
S
DevOps & Cloud Engineer. Building on AWS, containerising things, occasionally breaking prod. Writing about infrastructure, automation, and the stuff I wish someone had documented better.

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.

5 views