If you are writing Terraform by putting all your resources into a single main.tf file, you aren't building infrastructure; you are just writing a very long deployment script.
On Day 6 of my Terraform journey, I built a highly available web cluster. It worked perfectly. But if my team suddenly asked for a Staging environment, my only option would have been to copy and paste 200 lines of code. That is a maintenance nightmare.
Today, for Day 8 of the 30-Day Terraform Challenge, I ripped that monolithic architecture apart and converted it into a reusable Terraform Module. Here is a breakdown of how module architecture actually works, the calling patterns, and the difference between a module your team will love and one they will hate.
The Anatomy of a Module Directory
A module is simply a container for multiple resources that are used together. The moment you start using modules, your mental model must split into two concepts: the Child Module (the blueprint) and the Root Module (the execution environment).
Here is the directory structure I built to manage this:
terraform-project/
├── modules/
│ └── webserver/ # The Child Module (The Blueprint)
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── versions.tf
│ └── README.md
├── dev/ # The Root Module (The Execution)
│ ├── main.tf
│ ├── backend.tf
│ └── .terraform.lock.hcl
└── prod/ # The Root Module (The Execution)
├── main.tf
├── backend.tf
└── .terraform.lock.hcl
The Golden Rules of the Child Module
Notice what is missing from the modules/webserver folder: there is no provider "aws" block, and no backend "s3" block. A good module is completely agnostic. It should not know if it is being deployed to us-east-1 or eu-west-2, and it shouldn't care where its state file is stored. The Root environments (dev/ and prod/) handle the authentication and pass it down.
Inputs, Outputs, and the Calling Pattern
To make the blueprint reusable, you have to strip out every hardcoded value.
1. The Inputs (variables.tf)
Instead of naming my VPC "my-vpc", I introduced a cluster_name variable. I also removed the default values for my network CIDR blocks so the Root module is forced to provide them.
# modules/webserver/variables.tf
variable "cluster_name" {
description = "The prefix for all resources (e.g., dev-app, prod-app)"
type = string
}
variable "vpc_cidr" {
description = "The CIDR block for the VPC"
type = string
}
Inside the module's main.tf, every resource tag now dynamically references that input:
# modules/webserver/main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
tags = { Name = "${var.cluster_name}-vpc" }
}
2. The Calling Pattern
With the blueprint ready, spinning up an entire production-grade environment in prod/main.tf requires just a few lines of code. The Root module "calls" the Child module using the source argument.
# prod/main.tf
provider "aws" {
region = "us-east-1"
}
module "webserver" {
source = "../modules/webserver"
cluster_name = "prod-app"
vpc_cidr = "10.1.0.0/16"
instance_type = "t3.small"
# ... subnet maps go here ...
}
3. The Outputs (outputs.tf)
When resources are buried inside a module, the Root environment cannot automatically see them. If I want to know the DNS name of my Load Balancer after I deploy, the module must explicitly export it.
# modules/webserver/outputs.tf
output "alb_dns_name" {
value = aws_lb.alb.dns_name
}
The Root module then catches that output and displays it to the console:
# prod/main.tf
output "production_url" {
value = module.webserver.alb_dns_name
}
Easy vs. Painful Modules: Best Practices
Writing a module is easy. Writing a good module requires architectural foresight.
1. Avoid Hardcoding with "Sensible Defaults"
A painful module hardcodes configurations. If my module hardcoded the Load Balancer to port 80, and the Dev team needed to test a Node.js app on port 8080, they would be blocked.
An easy-to-use module provides a sensible default. In my variables.tf, I set the HTTP port to 80 by default. If the user doesn't specify a port, it works out of the box. If they need something custom, they can easily override it in their Root module.
2. Module Scope: When to Split
Right now, my webserver module deploys the network (VPC, Subnets, NAT) and the compute layer (ALB, ASG, EC2). For my personal lab, this is fine.
In the real world, this is an anti-pattern. Networking lifecycles are very different from application lifecycles. Best practice dictates splitting this into two modules: a vpc-network module and an app-compute module. This prevents a bad application deployment from accidentally tearing down the company's core routing tables.
3. Write a Useful README
A Terraform module without a README.md is practically useless to another engineer. Your README must act as the API documentation for your infrastructure. It should include:
An architecture diagram or summary.
An exact copy-paste example of how to call the module (
module { source = ... }).A markdown table detailing every required input variable, its type, and its purpose.
Final Thoughts
Moving from flat files to modules is the threshold where you stop writing scripts and start acting as a true Platform Engineer. You are building guardrails, standardizing deployments, and making it fundamentally easier for your team to safely consume cloud resources.
You can check out the full refactored code and directory structure on my GitHub.

Top comments (0)