← Back to list

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…

Lydiah · 2026-03-24 21:22 · 0 claps · 4.5 min read
#terraform-modules #infrastructure-as-code #cloud-infrastructure #aws-terraform-tutorial
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

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