Understand why modules are the foundation of IaC at enterprise scale. Learn the standard module structure, creating a reusable VPC module, calling local and remote modules, up to version pinning and its best practices.

After discussing Advanced State Manipulation in episode 8 — import, moved, and the terraform state command family — in this episode we move one abstraction level higher: Creating & Managing Reusable Modules.
Let's be honest for a moment. If your Terraform project is left to grow without rules, sooner or later main.tf will turn into a monster with thousands of lines managing VPC, subnets, security groups, instances, databases, load balancers, all the way to S3 buckets — everything in one file. Every time you want to add a new instance, you copy dozens of lines then change a few names. Change in one place, forget in another. In software engineering this is called copy-paste code, and we all know where it leads: inconsistency, drift between resources, and hard-to-trace errors.
Think of it like a restaurant chain. Imagine if every chef wrote recipes in their own format — one on a scrap of paper, one in a notebook, another one memorized. The result: the same menu item can taste different at every branch. The solution is recipe standardization: one standard format, one source of truth, reusable across all branches. In Terraform, that "standard recipe" is called a module.
In this episode you'll learn why modules matter, the standard structure of a module, how to create your own module, how to call local and remote modules from the Terraform Registry, and how to pin module versions to keep teams in control. This is the skill that differentiates config that "happens to work" from config that's "production-ready".
A module is a container for a set of resources used together. It works like a function in a programming language: receives input (variables), does internal work (resources), then returns results (outputs). The caller doesn't need to know the implementation details — just "what can be configured" and "what you get".
From this come five main advantages that make modules an industry standard:
There's no hard rule that a module must have specific file names — Terraform just reads all .tf files in that directory. But the community has agreed on the following convention so any module is easy to understand:
modules/
└── vpc/
├── main.tf # Main resource definitions
├── variables.tf # Input variable declarations
├── outputs.tf # Values exposed to the caller
├── versions.tf # Terraform & provider constraints
└── README.md # Usage documentation| File | Function |
|---|---|
main.tf | The main resources managed by the module (at least one .tf file is required). |
variables.tf | Input declarations — type, description, default, up to validation. |
outputs.tf | Values the caller can consume, e.g. vpc_id, subnet_ids. |
versions.tf | The terraform { required_version, required_providers } block to lock versions. |
README.md | Documentation: when to use the module, available variables, usage examples. |
Note
File names are just a convention — Terraform merges all .tf files in one directory into a single configuration. What's truly required is at least one resource (or pure data/output) inside the module. The five-file convention above makes modules consistent and easy to review, especially if they'll later be published to a module registry.
The best way to understand modules is to create one yourself. Let's build a reusable vpc module: accepting a name, CIDR, a list of public subnet CIDRs, and availability zones as inputs; managing the VPC, subnets, internet gateway, and route table; then exposing their IDs as outputs.
First, lock the versions in versions.tf:
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}Then the input declarations in variables.tf. Notice that every variable has a description and type — this isn't just style, but living documentation for the caller:
variable "name" {
description = "Name for the VPC and the resources inside it"
type = string
}
variable "cidr" {
description = "Main CIDR block of the VPC"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "List of availability zones for subnets"
type = list(string)
}
variable "public_subnet_cidrs" {
description = "List of CIDR blocks for public subnets"
type = list(string)
}
variable "tags" {
description = "Additional tags merged into all resources"
type = map(string)
default = {}
}Now main.tf — the heart of the module. Notice the merge(...) pattern to combine built-in tags with caller tags, and the use of count to create as many subnets as there are items in the list:
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(
{ Name = var.name },
var.tags,
)
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = merge(
{ Name = "${var.name}-public-${count.index + 1}" },
var.tags,
)
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(
{ Name = "${var.name}-igw" },
var.tags,
)
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = merge(
{ Name = "${var.name}-public" },
var.tags,
)
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}Finally, export the useful values in outputs.tf. Remember: only what's declared here can be accessed by the caller:
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
output "internet_gateway_id" {
description = "ID of the Internet Gateway"
value = aws_internet_gateway.this.id
}Tip
Always write description on variables and outputs. When a module is used by many teams, these descriptions are what appear when someone runs terraform plan or reads the documentation — without descriptions, your module feels like a black box that must be dismantled to understand.
The module above isn't useful until it's called. In the root directory, we declare the module with a module block — its source and inputs:
module "vpc" {
source = "./modules/vpc"
name = "app-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-southeast-1a", "ap-southeast-1b"]
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
tags = {
Environment = "dev"
ManagedBy = "terraform"
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_ids[0]
tags = {
Name = "web-server"
}
}Two important things above:
name, cidr, azs, etc. flow through the module's variable blocks.module.<name>.<output> — module.vpc.public_subnet_ids[0] takes the first subnet from the module. This is what forms the implicit dependency automatically: Terraform knows the instance depends on the VPC module, without needing depends_on.After writing the module block, run terraform init. Local modules don't need downloading, but Terraform will make sure the structure is valid:
terraform initInitializing modules...
- vpc in modules/vpc
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 5.0.0"...
- Installing hashicorp/aws v5.61.0...
- Installed hashicorp/aws v5.61.0 (signed by HashiCorp)
Terraform has been successfully initialized!Note
The terraform get command is the historical predecessor of terraform init, specifically for downloading modules. In modern Terraform, terraform init already covers module downloading (local and remote) along with provider installation and backend initialization. Use terraform init as the standard command; terraform get is only relevant if you want to just download modules without touching providers/backends.
There are two categories of module sources you'll encounter every day:
source = "./modules/vpc". The module lives in the same repo, usually in a modules/ folder. Suitable for internal code not yet shared across repos. Relative paths must start with ./ or ../ (writing source = "modules/vpc" without ./ will be treated as a registry name).module "vpc" {
source = "./modules/vpc"
name = "app-vpc"
cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24"]
azs = ["ap-southeast-1a"]
}Notice the details in the examples above:
<namespace>/<name>/<provider> format — here terraform-aws-modules/vpc/aws, the most widely used community VPC module in the world.git:: followed by the URL, then // to point to a subdirectory within the repo, and ?ref= to specify a tag/branch/commit. This is a common pattern for private module repositories.When terraform init runs with a remote module, the output will show the installation process from the registry:
Initializing modules...
Downloading terraform-aws-modules/vpc/aws 5.8.1 for vpc...
- vpc in .terraform/modules/vpc
Initializing the backend...Just like application dependencies, remote modules must be version-pinned with the version argument. Without pinning, terraform init will always pull the latest version and your infrastructure can change behavior without you realizing it. The writing rules are the same as provider version constraints:
| Constraint | Meaning |
|---|---|
version = ">= 1.0" | Any version from 1.0 upward (too loose) |
version = "~> 1.2" | Version 1.2.x — patches allowed, doesn't pass the minor |
version = ">= 1.0, < 2.0" | Range between 1.0 and 2.0 |
version = "1.2.5" / = 1.2.5 | Only exactly 1.2.5 (most strict) |
Warning
Local modules (./modules/...) don't support the version argument — they always follow the code in that directory, which is naturally versioned by git. For remote modules, it's mandatory to include version. For Git sources, use a ref pointing to a release tag (like ?ref=v1.2.0), not a branch like main, so changes on the branch don't silently enter production infrastructure.
Writing a module that "can be used" is easy; writing a module that other teams are comfortable using takes discipline. Several practices proven in the field:
type and description; give a default only if there's a generally sensible value.examples/ directory — this doubles as a test fixture (episode 11).required_providers in versions.tf.aws_vpc.this.id rather than guessing the same ID.README.md with a variables and outputs table — or use a generator like terraform-docs so the documentation stays in sync with the code.1. Forgetting to run terraform init after adding a new module
The most classic error when just learning: Error: Module not installed. This module is not yet installed. Run "terraform init" to install all modules required by this configuration. The solution is always the same — run terraform init.
2. Local source path without ./ or ../
source = "modules/vpc" will be interpreted as a registry module name, not a local path, and ends in an error like Error: Failed to query available provider packages or Module not found. Always write ./modules/vpc.
3. Module without versions.tf
Without provider constraints, two modules in one project can demand different provider versions, and init will fail with a conflict. Always declare required_providers and required_version in every module.
4. Placing provider configuration inside the module
A provider "aws" { region = "ap-southeast-1" } block inside a module isn't portable and triggers the "provider configurations are only allowed in the root module" warning. Region/credential matters are the caller's right.
5. Consuming outputs that aren't declared
module.vpc.subnet_ids will error with Unsupported attribute if the module doesn't export subnet_ids (in our example it's named public_subnet_ids). Check the module's outputs.tf before consuming.
6. Monolithic modules
One module managing VPC, EC2, RDS, and IAM all at once is an anti-pattern — it becomes a "monster" again, just relocated. Split it into small, focused modules, according to resource groups that are always used together.
In this episode 9 you've mastered the foundations of Terraform modularity: understanding why modules matter as a way to standardize reusable, battle-tested infrastructure code; getting to know the standard module structure (main.tf, variables.tf, outputs.tf, versions.tf, README.md); creating a complete VPC module with inputs, resources, and outputs; calling local and remote modules (Registry & Git); doing version pinning; as well as best practices and common mistakes often encountered by new teams.
Key takeaways to bring home:
./ for local modules, <namespace>/<name>/<provider> for the Registry, and git::...//subdir?ref= for Git.Modularized code does solve the duplication problem, but leaves one big question: how do you manage dev, staging, and prod simultaneously without colliding with each other? This is the topic that's often a source of conflict between teams.
In the next episode 10 we'll discuss Workspaces vs Directory-Based Multi-Environment — understanding the terraform workspace concept, its limitations that often trip people up, to the environment-based directory structure that's the enterprise standard. Stay excited!