How to Create and Use Terraform Modules for Reusable Code
Published on: November 9, 2023 | Author: DevOps Engineering Team
Welcome to Part 6 of our Terraform Mastery Series! You've learned about variables and outputs. Now it's time to level up your infrastructure code with modules. Terraform modules let you package and reuse configurations across projects and teams, making your infrastructure code more maintainable, scalable, and consistent.
What You'll Learn
What are Modules and Why Use Them?
Modules are containers for multiple Terraform resources that are used together. Think of them as functions in programming - they encapsulate logic and can be reused with different inputs.
Reusability
Write once, use everywhere. Create standardized infrastructure patterns that can be reused across projects and teams.
Abstraction
Hide complexity behind simple interfaces. Consumers don't need to understand the implementation details.
Organization
Break down complex infrastructure into logical, manageable components that are easier to understand and maintain.
Module Analogy
Think of modules like LEGO blocks. You can create standardized components (modules) and combine them in different ways to build complex structures (infrastructure) without reinventing the wheel each time.
Understanding the Root Module
Every Terraform configuration has at least one module - the root module. This is the directory where you run terraform apply.
my-terraform-project/
├── main.tf # Primary resources
├── variables.tf # Input variables
├── outputs.tf # Output values
├── terraform.tfvars # Variable values
└── terraform.tfstate # State file (generated)
The root module can call other modules (child modules) to compose more complex infrastructure:
my-terraform-project/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
├── networking/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── compute/
├── main.tf
├── variables.tf
└── outputs.tf
Calling Public Modules from Terraform Registry
The Terraform Registry hosts thousands of pre-built modules you can use in your configurations.
Find a Module
Browse the Terraform Registry for modules. For example, the AWS VPC module:
terraform-aws-modules/vpc/aws
Call the Module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 3.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
enable_vpn_gateway = true
tags = {
Terraform = "true"
Environment = "dev"
}
}
Use Module Outputs
output "vpc_id" {
value = module.vpc.vpc_id
}
output "private_subnets" {
value = module.vpc.private_subnets
}
Benefits of Public Modules
- Community tested: Used and validated by thousands of users
- Best practices: Implement AWS and Terraform best practices
- Time saving: Avoid reinventing common patterns
- Versioned: Pin to specific versions for stability
Creating Your Own Local Modules
Creating your own modules helps standardize infrastructure patterns within your organization.
Create Module Directory Structure
my-project/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
└── web-server/
├── main.tf
├── variables.tf
└── outputs.tf
Define Module Interface (Variables)
variable "name" {
type = string
description = "Name prefix for resources"
}
variable "instance_type" {
type = string
description = "EC2 instance type"
default = "t3.micro"
}
variable "instance_count" {
type = number
description = "Number of instances to create"
default = 1
}
variable "vpc_id" {
type = string
description = "VPC ID where instances will be created"
}
variable "subnet_ids" {
type = list(string)
description = "List of subnet IDs for instances"
}
Implement Module Logic
resource "aws_security_group" "web" {
name_prefix = "${var.name}-web-"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.name}-web-sg"
}
}
resource "aws_instance" "web" {
count = var.instance_count
ami = "ami-0c02fb55956c7d316" # Amazon Linux 2
instance_type = var.instance_type
subnet_id = var.subnet_ids[count.index % length(var.subnet_ids)]
vpc_security_group_ids = [aws_security_group.web.id]
user_data = file("${path.module}/user-data.sh")
tags = {
Name = "${var.name}-web-${count.index}"
}
}
Define Module Outputs
output "instance_ids" {
value = aws_instance.web.*.id
}
output "instance_private_ips" {
value = aws_instance.web.*.private_ip
}
output "security_group_id" {
value = aws_security_group.web.id
}
Module Structure and Best Practices
A well-structured module follows consistent patterns that make it easy to use and maintain.
main.tf
Purpose: Contains the primary resource definitions
Contains: AWS resources, data sources, providers
Best Practice: Keep it focused on the module's core purpose
variables.tf
Purpose: Defines the module's input interface
Contains: Input variable declarations with types and descriptions
Best Practice: Provide sensible defaults when possible
outputs.tf
Purpose: Defines what the module exposes to callers
Contains: Output values for important resources and computed values
Best Practice: Document outputs with descriptions
README.md
Purpose: Documentation for module users
Contains: Usage examples, input/output reference, requirements
Best Practice: Include complete usage examples
Module Design Principles
- Single Responsibility: Each module should do one thing well
- Clear Interface: Well-defined inputs and outputs
- Documentation: Clear usage instructions and examples
- Testing: Include examples and tests for different use cases
Passing Inputs to Modules
Modules accept inputs through variables, allowing customization for different use cases.
module "web_servers" {
source = "./modules/web-server"
name = "production"
instance_type = "t3.large"
instance_count = 3
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}
You can use the same module with different inputs for different environments:
# Development environment
module "web_dev" {
source = "./modules/web-server"
name = "dev"
instance_type = "t3.micro"
instance_count = 1
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}
# Production environment
module "web_prod" {
source = "./modules/web-server"
name = "prod"
instance_type = "t3.large"
instance_count = 3
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}
Using Module Outputs
Module outputs allow you to pass information from child modules to the root module or between modules.
# Reference outputs from the web-server module
output "web_instance_ids" {
value = module.web_servers.instance_ids
}
output "web_private_ips" {
value = module.web_servers.instance_private_ips
}
# Pass outputs between modules
module "load_balancer" {
source = "./modules/load-balancer"
name = "web"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnets
target_instance_ids = module.web_servers.instance_ids
security_group_id = module.web_servers.security_group_id
}
Module Versioning with Git
Versioning your modules ensures stability and allows controlled updates.
1. Store Modules in Git Repositories
Create separate repositories for your modules:
# Module repository structure
terraform-aws-web-server/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md
└── versions.tf
2. Tag Releases with Semantic Versioning
git tag v1.0.0
git tag v1.1.0
git tag v2.0.0
git push --tags
3. Reference Versioned Modules
module "web_server" {
source = "git::https://github.com/my-org/terraform-aws-web-server.git?ref=v1.2.0"
name = "production"
instance_type = "t3.large"
instance_count = 3
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}
Versioning Best Practices
- Use semantic versioning: MAJOR.MINOR.PATCH
- Tag releases: Create tags for stable versions
- Pin versions: Always specify version constraints
- Test upgrades: Test minor and patch versions before applying
- Maintain changelog: Document changes between versions
Complete Module Example
Here's a complete example of a reusable web server cluster module:
# modules/web-cluster/variables.tf
variable "name" { type = string }
variable "environment" { type = string }
variable "instance_type" { type = string; default = "t3.micro" }
variable "min_size" { type = number; default = 1 }
variable "max_size" { type = number; default = 3 }
variable "vpc_id" { type = string }
variable "subnet_ids" { type = list(string) }
# modules/web-cluster/main.tf
resource "aws_launch_template" "web" {
name_prefix = "${var.name}-${var.environment}-"
image_id = "ami-0c02fb55956c7d316"
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
user_data = base64encode(file("${path.module}/user-data.sh"))
}
resource "aws_autoscaling_group" "web" {
name = "${var.name}-${var.environment}-asg"
min_size = var.min_size
max_size = var.max_size
vpc_zone_identifier = var.subnet_ids
launch_template {
id = aws_launch_template.web.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.name}-${var.environment}"
propagate_at_launch = true
}
}
# modules/web-cluster/outputs.tf
output "asg_name" {
value = aws_autoscaling_group.web.name
}
output "security_group_id" {
value = aws_security_group.web.id
}
Key Takeaways
- Modules promote reusability: Write once, use across projects
- Standardize infrastructure: Enforce best practices organization-wide
- Abstract complexity: Hide implementation details behind clean interfaces
- Version control modules: Use Git tags for stable releases
- Compose complex systems: Build complex infrastructure from simple modules
- Leverage public modules: Save time with community-tested patterns
In our next tutorial, we'll dive into Terraform Workspaces and Remote State, where you'll learn how to manage multiple environments and collaborate effectively with your team.
No comments:
Post a Comment