1. The Core Concept: What is a Module?#
A Terraform Module is simply a standard folder containing one or more .tf files. That is it. There is no special syntax or file extension required to create a module.
In fact, every time you run Terraform, you are already using a module. The folder where you run terraform apply is technically called the Root Module.
When the Root Module calls another folder containing Terraform code, that called folder is referred to as a Child Module.
Why use Modules?#
- Reusability: Write the complex logic for an EKS Cluster once, and allow 10 different application teams to deploy it simply by providing a few variables.
- Encapsulation: Hide complex, messy resources (like IAM Roles, Security Groups, and Route Tables) behind a clean, simple interface.
- Versioning: By storing modules in separate Git repositories, you can tag them (e.g.,
v1.0.0). Teams can upgrade to newer versions of the infrastructure safely.
2. Practice: Building a VPC Child Module#
Let’s build a highly reusable AWS VPC module. We want this module to create a VPC, an Internet Gateway, and a Subnet, but we want the consumer (the Root Module) to decide the CIDR blocks and the Environment Name.
Step 2.1: The Directory Structure#
Create a folder structure like this:
terraform-projects/
├── modules/
│ └── vpc-module/ # <--- This is our Child Module
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── my-app-prod/ # <--- This is our Root Module
└── main.tfmkdir -p terraform-projects/modules/vpc-module
mkdir -p terraform-projects/my-app-prodStep 2.2: Writing the Child Module (The Logic)#
Navigate into the vpc-module folder.
1. Define the Inputs (variables.tf)
These are the arguments our module will accept.
# modules/vpc-module/variables.tf
variable "vpc_cidr" {
description = "The CIDR block for the entire VPC"
type = string
}
variable "subnet_cidr" {
description = "The CIDR block for the public subnet"
type = string
}
variable "env_name" {
description = "The name of the environment (e.g., prod, staging)"
type = string
}2. Define the Infrastructure (main.tf)
Notice how we DO NOT hardcode anything. We strictly use the variables.
# modules/vpc-module/main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
tags = {
Name = "${var.env_name}-vpc"
}
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.env_name}-igw"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.this.id
cidr_block = var.subnet_cidr
tags = {
Name = "${var.env_name}-public-subnet"
}
}3. Define the Outputs (outputs.tf)
The Root Module will likely need to know the IDs of the resources we just created so it can deploy EC2 instances into them.
# modules/vpc-module/outputs.tf
output "vpc_id" {
description = "The ID of the created VPC"
value = aws_vpc.this.id
}
output "public_subnet_id" {
description = "The ID of the created Public Subnet"
value = aws_subnet.public.id
}Congratulations! You have just authored a production-grade Terraform Module.
3. Practice: Consuming the Module#
Now, navigate to the my-app-prod folder (The Root Module). We are going to call our newly created Child Module and pass in the required arguments.
cd ../../my-app-prodCreate a main.tf file:
# my-app-prod/main.tf
provider "aws" {
region = "us-east-1"
}
# 1. Calling the Module
module "production_network" {
# The 'source' argument tells Terraform where to find the module.
# This can be a local path, a Git URL, or the Terraform Registry.
source = "../modules/vpc-module"
# Injecting values into the module's variables
env_name = "prod-app"
vpc_cidr = "10.10.0.0/16"
subnet_cidr = "10.10.1.0/24"
}
# 2. Using the Module's Outputs
resource "aws_instance" "web" {
ami = "ami-123456"
instance_type = "t3.micro"
# We extract the Subnet ID returned by the module!
subnet_id = module.production_network.public_subnet_id
}The Magic of the module Block#
By writing just 6 lines of code in the Root Module, we successfully deployed a VPC, an Internet Gateway, and a Subnet, perfectly tagged and parameterized.
If another team wants to deploy a Staging environment, they simply create a my-app-staging folder, call the exact same module, and pass env_name = "staging" and a different CIDR block. Absolute reusability.
4. Initializing and Executing Modules#
When you use a module, you MUST run terraform init before running terraform plan.
terraform initExpected Terminal Output:
Initializing modules...
- production_network in ../modules/vpc-module
Initializing the backend...
Initializing provider plugins...Terraform detects the module block, navigates to the relative path ../modules/vpc-module, and copies the module’s code into your hidden .terraform/modules directory.
Run terraform plan to verify the execution. You will see that Terraform intends to create the VPC, the Subnet, the IGW, and the EC2 instance, resolving all dependencies perfectly.
5. Remote Modules and Versioning (Git)#
Referencing modules via local relative paths (source = "../modules/") is fine for monorepos, but in large enterprises, you want to store your modules in dedicated Git repositories.
Terraform natively supports downloading modules directly from GitHub, GitLab, or Bitbucket.
module "production_network" {
# Terraform will git clone this repository automatically!
# Notice the ?ref=v1.2.0 tag. This guarantees IMMUTABILITY.
source = "git::https://github.com/my-company/terraform-aws-vpc-module.git?ref=v1.2.0"
env_name = "prod-app"
vpc_cidr = "10.10.0.0/16"
subnet_cidr = "10.10.1.0/24"
}By pinning the ref=v1.2.0, you ensure that even if the Platform Team introduces a breaking change in v2.0.0 of the module, your production Root Module remains completely unaffected until you manually update the ref string.
Troubleshooting & Common Errors#
Module not installed- Root Cause: You added a new
moduleblock or changed thesourceargument, but you attempted to runterraform planwithout initializing. - Solution: Always run
terraform initwhenever you modify module sources.
- Root Cause: You added a new
Missing required argument- Root Cause: The Child Module’s
variables.tfdeclares a variable without adefaultvalue, but you failed to pass that variable in the Root Module’smoduleblock. - Solution: Review the Child Module’s variables and ensure you pass all required inputs.
- Root Cause: The Child Module’s
Reference to undeclared output value- Root Cause: You tried to use
module.my_module.my_id, but the Child Module’soutputs.tffile does not actually output anything namedmy_id. - Solution: A Root Module cannot magically reach inside a Child Module’s state. The Child Module MUST explicitly declare an
outputblock for the Root Module to consume it.
- Root Cause: You tried to use
Conclusion & Next Steps#
You have successfully graduated from writing flat scripts to architecting enterprise-grade modules. You can now build self-service infrastructure catalogs for your entire company.
However, as your team grows, you will face a critical problem: What happens if two engineers run terraform apply at the exact same time on the exact same Root Module? The local .tfstate file will become desynchronized, leading to catastrophic infrastructure corruption.
In the final episode of Tier 2, Episode 10: Remote State Backends and Concurrency Locking, we will solve this by migrating our state file to AWS S3 and DynamoDB.

