Building Reusable Infrastructure with Terraform Modules
If you have been writing Terraform for more than a few days, you have probably noticed something: you keep writing the same things. The…
Building Reusable Infrastructure with Terraform Modules

If you have been writing Terraform for more than a few days, you have probably noticed something: you keep writing the same things. The same security group structure, the same load balancer setup, the same Auto Scaling Group configuration just with slightly different names each time.
By Day 5 of this challenge I had written essentially the same 80 lines of infrastructure code twice. That is the problem modules solve.
What is a Terraform module
A module is just a folder with Terraform files in it. That is it. What makes it useful is the discipline you apply to it no hardcoded values, clean inputs, and outputs that expose exactly what a caller needs. When you do that, the same infrastructure definition can be deployed to ten different environments without touching the module itself.
The directory structure
The convention that makes modules maintainable at scale looks like this:
Day-Eight/
├── modules/
│ └── services/
│ └── webserver-cluster/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── README.md
└── live/
├── dev/
│ └── services/
│ └── webserver-cluster/
│ └── main.tf
└── production/
└── services/
└── webserver-cluster/
└── main.tf
The modules/ folder is where the reusable logic lives. The live/ folder is where you call it — once per environment, with environment-specific inputs. The module never knows or cares which environment is calling it.
Inputs: making a module configurable
Every value that might differ between environments goes into variables.tf. For a web server cluster, that means the cluster name, instance type, scaling limits, and the port the server listens on:
variable "cluster_name" {
description = "The name to use for all cluster resources"
type = string
}
variable "instance_type" {
description = "EC2 instance type for the cluster"
type = string
default = "t2.micro"
}
variable "min_size" {
description = "Minimum number of EC2 instances in the ASG"
type = number
}
variable "max_size" {
description = "Maximum number of EC2 instances in the ASG"
type = number
}
variable "server_port" {
description = "Port the server uses for HTTP"
type = number
default = 8080
}
cluster_name, min_size, and max_size have no defaults they are required. If a caller does not pass them in, Terraform will stop and ask. instance_type and server_port have defaults that make sense for development but can be overridden for production.
Inside main.tf, every resource name references var.cluster_name so there are no naming conflicts when the module runs in multiple environments at the same time:
resource "aws_lb" "web" {
name = "${var.cluster_name}-alb"
load_balancer_type = "application"
subnets = data.aws_subnets.default.ids
security_groups = [aws_security_group.alb_sg.id]
}
The AMI is the one value I kept hardcoded inside the module. It is specific to eu-central-1 and unlikely to change between environments, so exposing it as a variable would add noise without adding real flexibility.
Outputs: exposing what callers need
Once the module creates infrastructure, the calling configuration needs a way to reference the results — the ALB DNS name to hit in the browser, the ASG name for monitoring. That is what outputs.tf is for:
output "alb_dns_name" {
value = aws_lb.web.dns_name
description = "The domain name of the load balancer"
}
output "asg_name" {
value = aws_autoscaling_group.web.name
description = "The name of the Auto Scaling Group"
}
In the calling configuration, you reference these as module.webserver_cluster.alb_dns_name — the module name you defined in the module block, dot the output name.
The calling pattern
This is what the dev environment’s main.tf looks like:
module "webserver_cluster" {
source = "../../../../modules/services/webserver-cluster"
cluster_name = "webservers-dev"
instance_type = "t2.micro"
min_size = 2
max_size = 4
server_port = 80
}
output "alb_dns_name" {
value = module.webserver_cluster.alb_dns_name
}
And production:
module "webserver_cluster" {
source = "../../../../modules/services/webserver-cluster"
cluster_name = "webservers-production"
instance_type = "t2.medium"
min_size = 4
max_size = 10
server_port = 80
}
Same module, different inputs. Dev runs t2.micro with a minimum of 2 instances. Production runs t2.medium with a minimum of 4. The module code itself did not change at all between the two.
The source path is a relative path from the calling configuration to the module folder. When you run terraform init, Terraform reads that path, finds the module, and copies it into .terraform/modules/. That is why you need to run terraform init every time you add a new module or change a source path.
What makes a module easy to use versus a pain
A module that is easy to use has a few characteristics. Every input variable has a clear description so a caller does not need to read the resource code to understand what to pass. Required variables have no defaults so Terraform surfaces missing values immediately rather than deploying something broken. Outputs expose everything a caller might reasonably need not just what the module author happened to think of.
A painful module is one where half the values are hardcoded inside main.tf, forcing callers to fork the module every time they need a slight variation. Or one with no outputs, so callers have to go digging through the state file to find the ALB DNS name. Or one with no README, leaving callers to reverse-engineer the input variables from the resource code.
The README is not optional. It is the first thing someone reads when they find your module. At minimum it should show a working usage example, list all inputs with their types and defaults, and list all outputs. Someone should be able to call your module correctly without opening a single .tf file.
Refactoring existing code into a module
When I looked at my Days 4 and 5 code side by side, they were almost identical the same security groups, the same ASG and ALB setup, just with day04 and day05 in the resource names. Refactoring them into a module meant replacing every hardcoded name with ${var.cluster_name}-resource-name, moving the sizing and instance type to variables, and deleting one of the two copies entirely. What was 160 lines across two files became one module and two 8-line calling configurations.
The decision of what to expose as a variable versus keep internal comes down to one question: will this value ever differ between environments or callers? If yes, it is a variable. If no, keep it internal and document why in the README.
Wrapping up
Modules are the point where Terraform stops feeling like a scripting tool and starts feeling like an engineering discipline. The directory structure, the input and output conventions, the calling pattern these are not arbitrary. They are how infrastructure teams share and reuse code without stepping on each other. Building your first module from scratch and deploying it across two environments in one day makes the concept click in a way that reading about it does not.
Full code for this is on my GitHub at github.com/LydiahLaw/terraform-30-day-challenge.
메타데이터
- post_id
- 1b7834f88e48
- slug
- building-reusable-infrastructure-with-terraform-modules-1b7834f88e48
- url
- https://medium.com/@LydLaw/building-reusable-infrastructure-with-terraform-modules-1b7834f88e48
- canonical_url
- https://medium.com/@LydLaw/building-reusable-infrastructure-with-terraform-modules-1b7834f88e48
- author_url
- https://medium.com/@LydLaw
- status
- ok
- fetched_at
- 2026-07-10 04:31:59