Why Modules Exist
Your networking config quietly grew to 300 lines. Your compute config references half of it. Staging needs the same setup as prod, but with smaller instances. And you’ve been copy-pasting blocks between directories like it’s 2005.
That’s the moment modules solve. A module is just a reusable, self-contained package of Terraform configuration. Write it once, call it from anywhere, feed it different inputs per environment. It’s the same idea as functions in programming — except most developers take three months of copy-paste pain before they finally break things into modules.
Every Terraform Config Is Already a Module
Here’s a fact that trips people up: the directory containing your .tf files? That’s called the root module. When you run terraform apply, you’re running the root module. There’s nothing special about it.
Child modules are directories you call from the root using a module block. Root = entry point. Child = reusable component. That’s the entire distinction.
Standard Module Structure
modules/vpc/
├── main.tf # Resource definitions
├── variables.tf # Input variable declarations
├── outputs.tf # Output value declarations
├── README.md # Documentation (optional but you'll wish you had it)
└── versions.tf # required_providers and required_version
None of this is enforced by Terraform. You could dump everything in one file and it’d work fine. But do yourself a favor and follow the convention — it’s what everyone expects. When someone opens your module at 2 AM during an incident, they shouldn’t have to figure out your custom file layout.
Building a Local Module
Here’s a VPC module. Nothing fancy, but it’s the pattern you’ll use a hundred times:
# modules/vpc/variables.tf
variable "name" {
type = string
description = "Name prefix for all resources"
}
variable "cidr" {
type = string
description = "VPC CIDR block"
default = "10.0.0.0/16"
}
variable "public_subnets" {
type = list(string)
description = "List of CIDRs for public subnets"
}
variable "private_subnets" {
type = list(string)
description = "List of CIDRs for private subnets"
}
variable "azs" {
type = list(string)
description = "Availability zones to deploy into"
}
variable "tags" {
type = map(string)
default = {}
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr
tags = merge(var.tags, { Name = var.name })
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${count.index + 1}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.azs[count.index]
tags = merge(var.tags, {
Name = "${var.name}-private-${count.index + 1}"
Tier = "private"
})
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
description = "ID of the created VPC"
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
description = "IDs of public subnets"
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
description = "IDs of private subnets"
}
Calling the Module from Root
# root/main.tf
module "vpc" {
source = "./modules/vpc" # relative path to module
name = "prod"
cidr = "10.0.0.0/16"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.3.0/24", "10.0.4.0/24"]
azs = ["us-east-1a", "us-east-1b"]
tags = {
Environment = "prod"
ManagedBy = "terraform"
}
}
# Use module outputs in other resources
resource "aws_instance" "app" {
subnet_id = module.vpc.private_subnet_ids[0]
...
}
output "vpc_id" {
value = module.vpc.vpc_id
}
Module Inputs and Outputs — The Contract
Think of variables.tf and outputs.tf as a module’s API contract. The variables define what goes in. The outputs define what comes out. Everything else — all the resources in main.tf — is the module’s private business. Callers can’t see it, can’t touch it.
This encapsulation is the whole point. You can completely gut and rewrite a module’s internals — swap out resource types, reorganize the logic, add new resources — and as long as the outputs stay the same, every caller keeps working. I’ve done this plenty of times in production. Swap an aws_nat_gateway for a cheaper alternative, nobody upstream notices.
Providers Within Modules
By default, a child module inherits the provider from whoever calls it:
# root: provider "aws" { region = "us-east-1" }
# module automatically uses the same provider
module "vpc" {
source = "./modules/vpc"
...
}
Where it gets interesting is aliased providers. If you need a module to deploy in a different region, you pass the provider explicitly:
provider "aws" { region = "us-east-1" }
provider "aws" { alias = "west"; region = "us-west-2" }
module "vpc_west" {
source = "./modules/vpc"
providers = {
aws = aws.west # Pass the aliased provider to the module
}
...
}
This trips up a surprising number of people the first time they need multi-region deployments.
Module Directory Structure Matters
After terraform init, modules get downloaded into .terraform/modules/:
.terraform/
└── modules/
├── modules.json # Index of installed modules
└── vpc/ # Local module (symlinked or copied)
Registry modules land here too. Whatever you do, don’t edit files in .terraform/. That directory is Terraform’s workspace, not yours. Your changes will be blown away on the next init.
Design Scenario: Multi-Environment Deployment Using One Module
environments/
├── dev/
│ ├── main.tf
│ └── dev.tfvars
└── prod/
├── main.tf
└── prod.tfvars
modules/
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tf
# environments/dev/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "dev"
cidr = "10.1.0.0/16"
public_subnets = ["10.1.1.0/24"]
private_subnets = ["10.1.2.0/24"]
azs = ["us-east-1a"]
}
# environments/prod/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "prod"
cidr = "10.0.0.0/16"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.3.0/24", "10.0.4.0/24"]
azs = ["us-east-1a", "us-east-1b"]
}
One module definition. Two completely separate environments. The module doesn’t know or care about environments — it just takes inputs. Dev gets one AZ and one subnet because you don’t need HA for throwaway dev boxes. Prod gets two AZs because, well, it’s prod.
Key Takeaways
After adding a new module source or changing an existing one, you must re-run terraform init. Forget this and terraform plan will fail with a confusing “module not installed” error. I’ve watched senior engineers lose 20 minutes to this one.
Module outputs are not automatically surfaced when you run terraform output. Only root module outputs appear there. If you need a module’s output accessible outside Terraform (for scripts, CI, etc.), you have to explicitly re-export it as a root output: output "vpc_id" { value = module.vpc.vpc_id }.
Keep modules small and focused — a VPC module, a security group module, a compute module. I’ve seen teams build a “deploy-everything” module that’s 800 lines long and takes 47 input variables. That’s not a module. That’s a monolith wearing a trenchcoat. Compose small modules together. That’s where the real power is.

