What Is a Terraform Module?
A module is a directory containing .tf files. That’s it. Every Terraform project is already a module (the “root module”). When you call one module from another, it becomes a “child module.”
project/
├── main.tf ← Root module
├── variables.tf
├── outputs.tf
└── modules/
└── vpc/ ← Child module
├── main.tf
├── variables.tf
└── outputs.tf
Modules let you write infrastructure once and reuse it across projects, environments, and teams.
Using a Module
Local Module
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = "production"
project_name = "myapp"
}
# Use module outputs
resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_ids[0]
}
Registry Module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
}
Git Module
module "vpc" {
source = "git::https://gitlab.com/myorg/terraform-modules.git//vpc?ref=v1.2.0"
vpc_cidr = "10.0.0.0/16"
}
Writing a Module: Complete Example
Module Structure
modules/vpc/
├── main.tf # Resources
├── variables.tf # Inputs
├── outputs.tf # Outputs
├── versions.tf # Provider requirements
└── README.md # Documentation
variables.tf — Module Inputs
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
variable "public_subnet_count" {
description = "Number of public subnets"
type = number
default = 2
}
variable "private_subnet_count" {
description = "Number of private subnets"
type = number
default = 2
}
variable "enable_nat_gateway" {
description = "Enable NAT Gateway for private subnets"
type = bool
default = true
}
variable "tags" {
description = "Additional tags for all resources"
type = map(string)
default = {}
}
main.tf — Resources
data "aws_availability_zones" "available" {
state = "available"
}
locals {
azs = slice(data.aws_availability_zones.available.names, 0, max(var.public_subnet_count, var.private_subnet_count))
common_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
})
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(local.common_tags, {
Name = "${var.environment}-vpc"
})
}
resource "aws_subnet" "public" {
count = var.public_subnet_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = local.azs[count.index % length(local.azs)]
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = "${var.environment}-public-${local.azs[count.index % length(local.azs)]}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
count = var.private_subnet_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 100)
availability_zone = local.azs[count.index % length(local.azs)]
tags = merge(local.common_tags, {
Name = "${var.environment}-private-${local.azs[count.index % length(local.azs)]}"
Tier = "private"
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, {
Name = "${var.environment}-igw"
})
}
outputs.tf — Module Outputs
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "vpc_cidr" {
description = "CIDR block of the VPC"
value = aws_vpc.main.cidr_block
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
versions.tf — Provider Requirements
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
Module Composition: Building from Smaller Modules
# Root module composes multiple child modules
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = "prod"
}
module "database" {
source = "./modules/rds"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
engine = "postgresql"
}
module "app" {
source = "./modules/ecs-service"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
db_endpoint = module.database.endpoint
desired_count = 3
}
Each module handles one concern. The root module wires them together.
Best Practices
1. Keep Modules Small and Focused
One module = one logical component. A VPC module, a database module, a service module — not an “everything” module.
2. Always Define Input Validation
variable "instance_type" {
type = string
validation {
condition = can(regex("^t[23]\\.", var.instance_type))
error_message = "Only t2 and t3 instance types allowed."
}
}
3. Version Pin Registry Modules
# Good
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
# Bad — no version pin
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
}
4. Use Consistent File Structure
Every module should have:
main.tf— resourcesvariables.tf— all inputsoutputs.tf— all outputsversions.tf— provider requirementsREADME.md— usage examples
5. Don’t Hardcode Provider Configuration
Modules should inherit the provider from the calling module:
# Bad — provider config in module
provider "aws" {
region = "us-east-1"
}
# Good — module inherits provider from caller
# (no provider block in the module)
6. Use Description on Everything
variable "vpc_cidr" {
description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
type = string
}
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
7. Tag Everything via a Common Variable
variable "tags" {
description = "Common tags for all resources"
type = map(string)
default = {}
}
# In resources:
tags = merge(var.tags, { Name = "..." })
Common Errors
“Module not installed”
Error: Module not installed
Run terraform init to download modules.
“Unsupported attribute” on module output
The module doesn’t expose that output. Check outputs.tf in the module.
Cycle between modules
Modules can’t have circular dependencies. Restructure so data flows one direction.
Hands-On Courses
Learn by doing with interactive courses on CopyPasteLearn:
Conclusion
A Terraform module is just a directory with .tf files. Keep modules small (one component each), validate inputs, version-pin registry sources, expose useful outputs, and document everything with descriptions. Use module composition to build complex infrastructure from simple, tested building blocks.




