Terraform modules are the foundation of scalable infrastructure as code. They enable you to write reusable, maintainable infrastructure code that you can share across projects, teams, and organizations. Learning modules properly transforms you from writing basic Terraform to building enterprise-grade infrastructure.
Module Fundamentals
A Terraform module is a folder containing Terraform files that define infrastructure resources as a reusable, self-contained unit. It's a way to package related resources (VPC, subnets, security groups) into a single component you can use across multiple projects.
Key concept: Modules are like functions in programming. You write a function once, then call it multiple times with different parameters. Terraform modules work the same wayβdefine infrastructure once in a module, then use it in many projects.
Module concept
# Project 1 - main.tf
resource "aws_vpc" "vpc" { ... }
resource "aws_subnet" "public" { ... }
# Project 2 - main.tf (same code again!)
resource "aws_vpc" "vpc" { ... }
resource "aws_subnet" "public" { ... }
# modules/vpc/main.tf
resource "aws_vpc" "vpc" { ... }
resource "aws_subnet" "public" { ... }
# Project 1 - main.tf
module "vpc" {
source = "../modules/vpc"
}
# Project 2 - main.tf
module "vpc" {
source = "../modules/vpc"
}
Modules provide major benefits: code reusability, maintainability, consistency, organization, and testing. Without modules, you duplicate code, make updates difficult, and create inconsistencies. Modules solve all these problems.
Real example: You manage 5 AWS projects, each needing identical VPC setup. Without modules: update one setting = fix it in 5 places. With modules: update once in the module, all 5 projects inherit the change instantly.
Benefits comparison
Code Reusability: β Copy-paste 200+ lines 5 times
Maintainability: β Update code in 5 places
Organization: β 1000+ line monolithic file
Consistency: β Each person configures differently
Code Reusability: β
Write once, use everywhere
Maintainability: β
Update module, affects all uses
Organization: β
Logical, focused components
Consistency: β
Same configuration every time
Use modules when you have reusable infrastructure or multiple projects. Start with modules from day one if building multiple environments or projects. Even a single project benefits from modular structure.
Decision Tree
- Single project, simple (< 10 resources): Can keep in single main.tf, but modules still improve organization
- Single project, complex (> 10 resources): MUST USE MODULES for organization
- Multiple projects (same infrastructure): DEFINITELY USE MODULES for code reuse
- Enterprise (many teams, many projects): USE MODULE REGISTRY with versioning
Module Structure & Organization
A module is just a directory with Terraform files. The standard structure includes main.tf (resources), variables.tf (inputs), outputs.tf (returns), and optionally README.md (documentation).
Standard module structure
project/
βββ main.tf
βββ terraform.tfvars
βββ outputs.tf
βββ variables.tf
βββ modules/
βββ vpc/
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
β βββ README.md
βββ rds/
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
βββ security/
βββ main.tf
βββ variables.tf
βββ outputs.tf
Why this structure: Following the standard layout makes modules predictable. Other Terraform developers expect this organization. It's the community standard.
main.tf
Defines all resources. Create VPCs, subnets, databases, security groups here.
variables.tf
Defines input parameters. These are values callers pass in to customize the module.
outputs.tf
Returns values from the module. Callers use these to get results (IDs, endpoints, etc.).
README.md
Documents the module. Include usage example, input descriptions, and output explanations.
Example files
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidr
}
variable "cidr_block" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
Modules can be loaded from multiple sources: local filesystem, Git repositories, Terraform Registry, S3, and more. This flexibility enables code sharing across teams and organizations.
Different module sources
module "vpc" {
source = "../modules/vpc"
}
module "vpc" {
source = "git::https://github.com/company/modules.git//vpc?ref=v1.0.0"
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
module "vpc" {
source = "s3::https://s3.amazonaws.com/my-bucket/modules/vpc.zip"
}
Every Terraform configuration has a root module (the directory where you run terraform commands). Child modules are modules called by the root module. The root module orchestrates everything, child modules provide components.
Module hierarchy
module "network" {
source = "./modules/vpc"
cidr = var.network_cidr
}
module "database" {
source = "./modules/rds"
subnet = module.network.subnet_id
}
User runs: terraform apply
β
Root module gets user variables
β
Root module calls child modules
β
Child modules create infrastructure
β
Child modules return outputs to root
β
Root module returns final outputs to user
Module Variables & Inputs
Input variables make modules flexible. Instead of hardcoding values, define variables that callers pass in. This way, one module can create different infrastructure based on inputs.
Module variables example
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
variable "environment" {
description = "Environment name"
type = string
}
module "prod_vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = "production"
}
module "dev_vpc" {
source = "./modules/vpc"
vpc_cidr = "10.1.0.0/16"
environment = "development"
}
Variables have types: string, number, bool, list, map, etc. Terraform validates types, catching errors early. Add validation rules to ensure inputs are reasonable.
Variable types and validation
variable "instance_name" {
type = string
}
variable "instance_count" {
type = number
}
variable "enable_monitoring" {
type = bool
}
variable "availability_zones" {
type = list(string)
}
variable "tags" {
type = map(string)
}
variable "port" {
type = number
validation {
condition = var.port >= 1 && var.port <= 65535
error_message = "Port must be 1-65535"
}
}
Variables with defaults are optional. Variables without defaults are required. Critical settings should be required. Advanced options should have sensible defaults.
Defaults vs required
variable "environment" {
description = "Environment (prod/staging/dev)"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "enable_encryption" {
description = "Enable encryption?"
type = bool
default = true
}
Mark sensitive variables with sensitive=true to prevent Terraform from displaying them in logs. This protects passwords, API keys, and other secrets.
Sensitive variables
variable "database_password" {
description = "RDS master password"
type = string
sensitive = true
}
export TF_VAR_database_password="secret"
terraform apply
Module Outputs
Module outputs return values that other modules or the root module can use. Define outputs in outputs.tf, then reference them with module.name.output_name.
Module outputs example
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
output "public_subnet_id" {
description = "ID of public subnet"
value = aws_subnet.public.id
}
module "vpc" {
source = "./modules/vpc"
}
module "rds" {
source = "./modules/rds"
subnet_id = module.vpc.public_subnet_id
vpc_id = module.vpc.vpc_id
}
Function analogy: In programming, functions return values. Terraform modules return values via outputs. This enables composition: one module's output becomes another's input.
Outputs can return any value type: strings, numbers, lists, maps, objects. Mark outputs sensitive=true if they contain passwords or API keys.
Various output types
output "database_endpoint" {
value = aws_db_instance.this.endpoint
}
output "instance_ids" {
value = aws_instance.this[*].id
}
output "instance_ips" {
value = {
for inst in aws_instance.this :
inst.tags.Name => inst.private_ip
}
}
output "database_password" {
value = aws_db_instance.this.password
sensitive = true
}
Always describe outputs clearly. Users need to know what each output contains and how to use it. Good descriptions prevent confusion and misuse.
Well-documented outputs
output "vpc_id" {
description = "ID of the created VPC. Use this when creating resources that must connect to this VPC."
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "List of private subnet IDs. Pass these to RDS subnet group for database deployment."
value = aws_subnet.private[*].id
}
Advanced Module Patterns
Compose modules by calling multiple modules in your root configuration. Each module handles one concern (VPC, RDS, security), then you wire them together.
Module composition example
module "networking" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
}
module "security" {
source = "./modules/security"
vpc_id = module.networking.vpc_id
}
module "database" {
source = "./modules/rds"
subnet_ids = module.networking.private_subnet_ids
sg_id = module.security.rds_security_group_id
}
β Each module has single responsibility
β Independently testable
β Easy to reuse in other projects
β Changes to one module don't break others
β Clear data flow through outputs/inputs
When modules live in Git repos, use Git tags for versioning (v1.0.0, v1.1.0, v2.0.0). Callers pin to specific versions to ensure stability.
Module versioning
git tag -a v1.0.0 -m "Initial release"
git push --tags
module "vpc" {
source = "git::https://github.com/company/tf-modules.git//vpc?ref=v1.1.0"
}
v1.0.0 = first release (safe to use)
v1.1.0 = backward compatible feature
v1.1.1 = bug fix
v2.0.0 = breaking change (requires action!)
Test modules with terraform validate and terraform plan. Create test configurations that use the module with various inputs. Verify outputs and resources are created correctly.
Testing modules
cd modules/vpc
terraform init
terraform validate
# test/main.tf
module "vpc_test" {
source = ".."
cidr_block = "10.0.0.0/16"
environment = "test"
}
cd test
terraform plan
β Validate syntax (terraform validate)
β Plan with minimal inputs
β Plan with all variables
β Verify all outputs exist
β Test variable validation
β Check dependencies
β Verify no hardcoded secrets
Follow best practices: keep modules focused (one responsibility), use clear naming, document thoroughly, validate inputs, handle defaults wisely, make outputs comprehensive, version everything, and test systematically.
Production mindset: Someone else will use your module. Write it as if you won't be around to explain it. Clear names, complete documentation, and validation go a long way.
Best Practices Checklist
- Design: Single responsibility, clear variable names, required for critical settings, sensible defaults
- Organization: main.tf, variables.tf, outputs.tf, README.md, examples/, tests/
- Documentation: Clear README with examples, description for every variable and output
- Quality: Validate syntax, test with multiple configs, pin versions, run linting
- Security: No hardcoded secrets, mark sensitive variables, restrict IAM policies, enable encryption