Introduction to Terraform Modules
Terraform modules are collections of .tf files in a directory that encapsulate infrastructure components. They are the primary mechanism for code reuse, abstraction, and maintainability in Terraform projects. Whether you're building a small networking component or an entire application stack, modules enable you to write infrastructure code once and deploy it repeatedly across environments.
What is a Module?
A module in Terraform is simply:
- A directory containing
.tffiles - Input variables (optional parameters)
- Output values (exported data)
- Resources that work together to create infrastructure
The simplest module is your root configuration (the directory where you run terraform init). However, the real power comes from creating reusable modules that you can compose into larger infrastructures.
Why Use Modules?
Benefits of Modular Infrastructure
1. Code Reuse
Don't repeat the same 300 lines of configuration for creating application stacks. Write once, reuse everywhere:
# Instead of repeating this in every project...
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main-vpc"
}
}
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
}
# ... 20 more resources
# Use a module instead:
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
}2. Abstraction & Simplified Interface
Hide complexity behind simple inputs. A user doesn't need to understand 50 resources; they just need to know: "Give me a production-ready Kubernetes cluster":
module "eks_cluster" {
source = "terraform-aws-modules/eks/aws"
cluster_name = "production"
cluster_version = "1.27"
# 50 resources created automatically
}3. Version Control & Consistency
- Modules in Git ensure infrastructure is versioned
- Tag releases:
v1.2.3contains known-good infrastructure patterns - Teams pull specific module versions for controlled updates
4. Team Collaboration
- Platform teams create infrastructure modules
- Application teams reuse with confidence
- Standards enforced at the module level, not repeated in each project
5. Environment Parity
- Same module logic in dev, staging, production
- Different variables produce consistent, reproducible infrastructure
- Reduces "it works in dev but not in prod" issues
Module Structure
Basic Module Layout
modules/
├── vpc/
│ ├── main.tf # Resource definitions
│ ├── variables.tf # Input variables
│ ├── outputs.tf # Output values
│ └── README.md # Documentation
├── eks/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── README.md
└── rds/
├── main.tf
├── variables.tf
├── outputs.tf
└── README.md
Minimal Module Example
modules/web_server/main.tf:
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = var.server_name
}
}modules/web_server/variables.tf:
variable "ami_id" {
description = "AMI ID for the EC2 instance"
type = string
}
variable "instance_type" {
description = "Instance type (t3.micro, t3.small, etc.)"
type = string
default = "t3.micro"
}
variable "server_name" {
description = "Name tag for the instance"
type = string
}modules/web_server/outputs.tf:
output "instance_id" {
description = "ID of the created EC2 instance"
value = aws_instance.web.id
}
output "private_ip" {
description = "Private IP address of the instance"
value = aws_instance.web.private_ip
}
output "public_ip" {
description = "Public IP address of the instance"
value = aws_instance.web.public_ip
}Root module main.tf:
module "web_production" {
source = "./modules/web_server"
ami_id = "ami-0c55b159cbfafe1f0"
instance_type = "t3.small"
server_name = "production-web-01"
}
output "web_ip" {
value = module.web_production.public_ip
}Module Sources
Local Modules
Modules in your project directory:
module "networking" {
source = "./modules/networking"
}
module "nested" {
source = "../../shared-modules/database"
}Terraform Registry Modules
Official, versioned modules from Hashicorp:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
}
module "security_group" {
source = "terraform-aws-modules/security-group/aws"
version = "~> 5.0"
name = "my-sg"
}Registry URL: https://registry.terraform.io
Git Sources
Modules in remote Git repositories:
# GitHub
module "networking" {
source = "git::https://github.com/example-org/terraform-modules.git//vpc?ref=v1.2.3"
}
# GitLab
module "database" {
source = "git::https://gitlab.com/company/terraform.git//database?ref=main"
}
# Private repos (with SSH)
module "app" {
source = "git::ssh://[email protected]/company/private-modules.git//application"
}HTTP Sources
Modules served over HTTP:
module "s3_bucket" {
source = "https://example.com/modules/s3-bucket.zip"
}Terraform Cloud/Enterprise Registry
Private module registries hosted by Terraform Cloud:
module "database" {
source = "app.terraform.io/company/rds/aws"
version = "2.1.0"
}Module Composition Patterns
Pattern 1: Simple Web Application Stack
Create a complete application environment with one module:
# modules/app_stack/main.tf
module "vpc" {
source = "./vpc"
environment = var.environment
}
module "security_groups" {
source = "./security_groups"
vpc_id = module.vpc.id
}
module "database" {
source = "./rds"
vpc_id = module.vpc.id
sg_id = module.security_groups.database_sg_id
}
module "app_servers" {
source = "./ec2"
vpc_id = module.vpc.id
sg_id = module.security_groups.app_sg_id
db_endpoint = module.database.endpoint
}
module "load_balancer" {
source = "./alb"
vpc_id = module.vpc.id
instances = module.app_servers.instance_ids
}
# Root main.tf
module "production" {
source = "./modules/app_stack"
environment = "production"
}
output "app_url" {
value = module.production.load_balancer_url
}Pattern 2: Multi-Environment Infrastructure
Reuse modules across environments with different variables:
# environments/dev/main.tf
module "app" {
source = "../../modules/app_stack"
environment = "dev"
instance_count = 1
instance_type = "t3.micro"
}
# environments/prod/main.tf
module "app" {
source = "../../modules/app_stack"
environment = "production"
instance_count = 3
instance_type = "t3.xlarge"
enable_backups = true
}Pattern 3: Composable Modules
Create small, focused modules that work together:
# Root configuration composes modules
module "base_infrastructure" {
source = "./modules/networking"
}
module "security" {
source = "./modules/security"
vpc_id = module.base_infrastructure.vpc_id
}
module "compute" {
source = "./modules/compute"
vpc_id = module.base_infrastructure.vpc_id
sg_id = module.security.sg_id
}
module "storage" {
source = "./modules/storage"
environment = var.environment
}
module "monitoring" {
source = "./modules/observability"
instance_ids = module.compute.instance_ids
}Module Variables & Outputs
Variable Best Practices
# Good: Clear, descriptive variables
variable "instance_type" {
description = "EC2 instance type (e.g., t3.micro, t3.small)"
type = string
default = "t3.micro"
validation {
condition = can(regex("^[tm][0-9].*", var.instance_type))
error_message = "Instance type must be a valid AWS type."
}
}
variable "environment" {
description = "Environment name: dev, staging, or production"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
variable "tags" {
description = "Common tags for all resources"
type = map(string)
default = {
ManagedBy = "Terraform"
Project = "example"
}
}
variable "environment_config" {
description = "Complex environment configuration"
type = object({
instance_type = string
instance_count = number
enable_backup = bool
allowed_cidrs = list(string)
})
}Output Best Practices
# Useful outputs for consumers of the module
output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.web.id
}
output "private_ip" {
description = "Private IP address"
value = aws_instance.web.private_ip
sensitive = false
}
output "database_connection_string" {
description = "Connection string for database access"
value = "postgresql://${aws_db_instance.main.username}@${aws_db_instance.main.endpoint}/mydb"
sensitive = true
}
output "cluster_info" {
description = "Full cluster configuration"
value = {
cluster_id = aws_eks_cluster.main.id
endpoint = aws_eks_cluster.main.endpoint
certificate = base64decode(aws_eks_cluster.main.certificate_authority[0].data)
role_arn = aws_eks_cluster.main.role_arn
}
}Advanced Module Features
Dynamic Blocks for Complex Configurations
variable "security_group_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidrs = list(string)
}))
}
resource "aws_security_group" "main" {
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.security_group_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidrs
}
}
}Conditional Resource Creation
variable "enable_monitoring" {
type = bool
default = true
}
resource "aws_cloudwatch_alarm" "cpu" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "${var.instance_name}-high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = "2"
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = "120"
statistic = "Average"
threshold = "80"
}For-Each Module Instantiation
Create multiple instances of a module:
variable "environments" {
type = map(object({
instance_count = number
instance_type = string
}))
default = {
dev = {
instance_count = 1
instance_type = "t3.micro"
}
staging = {
instance_count = 2
instance_type = "t3.small"
}
production = {
instance_count = 3
instance_type = "t3.large"
}
}
}
module "app_env" {
for_each = var.environments
source = "./modules/app_stack"
environment = each.key
instance_count = each.value.instance_count
instance_type = each.value.instance_type
}
# Deploy to all environments without repeating configurationReal-World Module Example: EKS Cluster
A practical example showing how to create a reusable Kubernetes cluster module:
# modules/eks_cluster/variables.tf
variable "cluster_name" {
type = string
}
variable "kubernetes_version" {
type = string
default = "1.27"
}
variable "desired_node_count" {
type = number
default = 3
}
variable "instance_types" {
type = list(string)
default = ["t3.medium"]
}
# modules/eks_cluster/main.tf
resource "aws_eks_cluster" "main" {
name = var.cluster_name
version = var.kubernetes_version
role_arn = aws_iam_role.eks_service.arn
vpc_config {
subnet_ids = var.subnet_ids
}
}
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "${var.cluster_name}-nodes"
node_role_arn = aws_iam_role.eks_nodes.arn
subnet_ids = var.subnet_ids
scaling_config {
desired_size = var.desired_node_count
min_size = 1
max_size = var.desired_node_count * 2
}
instance_types = var.instance_types
}
# modules/eks_cluster/outputs.tf
output "cluster_endpoint" {
value = aws_eks_cluster.main.endpoint
}
output "cluster_ca_certificate" {
value = base64decode(aws_eks_cluster.main.certificate_authority[0].data)
}
# Usage in root module
module "eks" {
source = "./modules/eks_cluster"
cluster_name = "production-k8s"
kubernetes_version = "1.27"
desired_node_count = 5
instance_types = ["t3.large"]
subnet_ids = module.vpc.subnet_ids
}Module Best Practices
1. Single Responsibility
Each module should have one primary purpose. A VPC module creates networking. A database module creates databases. Don't mix concerns.
# Good: One responsibility
module "vpc" {
source = "./modules/vpc"
}
# Bad: Module does too much
module "entire_infrastructure" {
source = "./modules/everything" # VPC + DB + EC2 + monitoring...
}2. Sensible Defaults
Provide defaults for common use cases, but require explicit configuration for security-sensitive settings:
variable "instance_type" {
default = "t3.micro" # Non-critical: ok to default
}
variable "database_password" {
# NO default - force explicit configuration
sensitive = true
}
variable "enable_deletion_protection" {
default = true # Safe default
}3. Comprehensive Documentation
Every module should include a README explaining inputs, outputs, and usage:
# VPC Module
Creates a production-grade VPC with public/private subnets.
## Variables
- `vpc_cidr`: VPC CIDR block (e.g., "10.0.0.0/16")
- `public_subnets`: List of public subnet CIDR blocks
- `private_subnets`: List of private subnet CIDR blocks
## Outputs
- `vpc_id`: VPC identifier
- `nat_gateway_id`: NAT gateway for private subnets
## Example
```hcl
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
}
### 4. **Version Your Modules**
When sharing modules via Git or the Registry, use semantic versioning:
```bash
# Tag releases
git tag v1.0.0
git tag v1.1.0 # Minor update
git tag v2.0.0 # Breaking change
# Reference specific versions
module "vpc" {
source = "git::https://github.com/org/modules.git//vpc?ref=v1.1.0"
}
5. Handle Module Updates Carefully
- Minor updates (bug fixes, new optional variables): safe to apply
- Major updates (resource renames, removed variables): test in non-production first
# Tight version constraint for stability
module "database" {
source = "terraform-aws-modules/rds/aws"
version = "~> 5.0" # Allows 5.0.x but not 6.0.0
}Debugging Modules
Inspecting Module State
# See all resources created by a module
terraform state list | grep '^module.vpc'
# Inspect a specific resource in a module
terraform state show module.vpc.aws_vpc.main
# Debug outputs
terraform output -rawCommon Module Issues
Issue: Variables not being passed correctly
# Wrong: Missing source
module "vpc" {
cidr = "10.0.0.0/16"
}
# Right: Source is required
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
}Issue: Output references failing
# Wrong: Can't reference resources directly
resource "aws_instance" "app" {
subnet_id = aws_subnet.main.id # This subnet is in a module!
}
# Right: Reference module output
resource "aws_instance" "app" {
subnet_id = module.vpc.subnet_id
}Summary
Modules are essential for scalable Terraform projects. They enable:
✅ Code reusability across projects and teams ✅ Infrastructure as self-service for application teams ✅ Consistent patterns and best practices ✅ Version control and release management ✅ Reduced configuration errors through abstraction
Start small with local modules for your own projects, then graduate to sharing via the Terraform Registry or private Git repositories as your team grows.