G
GuideDevOps
Lesson 9 of 14

Modules

Part of the Terraform tutorial series.

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 .tf files
  • 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.3 contains 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://git@github.com/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 configuration

Real-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 -raw

Common 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.