Terraform Module Composition
Introduction
Terraform module composition is the practice of combining multiple Terraform modules to create complex infrastructure setups in a maintainable and scalable way. Think of modules as building blocks that you can arrange and combine to construct your infrastructure. Just as software developers use functions and classes to organize code, infrastructure engineers use module composition to organize infrastructure code.
In this guide, we'll explore different strategies for composing modules, best practices, and real-world examples to help you master this essential Terraform skill.
Understanding Module Composition
What is Module Composition?
Module composition is the process of using multiple modules together to create a larger infrastructure system. There are several approaches to module composition:
- Nesting: Where one module calls another module
- Layering: Where modules are arranged in hierarchical layers
- Aggregation: Where modules are combined side-by-side at the same level
Let's explore each approach with examples.
Nesting Modules
Nesting is when one module calls another module within its own code. This creates a parent-child relationship between modules.
Example: Web Application with Database
Here's a simple example of nesting modules for a web application:
// main.tf
module "web_app" {
source = "./modules/web_app"
name = "my-application"
environment = "production"
}
Inside the web_app
module, we call another module for the database:
// modules/web_app/main.tf
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "${var.name}-web-server"
Environment = var.environment
}
}
module "database" {
source = "../database"
name = "${var.name}-db"
environment = var.environment
engine = "postgres"
instance_type = "db.t3.medium"
}
output "web_url" {
value = "http://${aws_instance.web.public_dns}"
}
output "database_endpoint" {
value = module.database.endpoint
}
In this example, the web_app
module nests the database
module, creating a parent-child relationship.
Layering Modules
Layering organizes modules in a hierarchy, with each layer building on the previous one:
- Base Layer: Core infrastructure (VPC, subnets, security groups)
- Service Layer: Application-specific resources (EC2, RDS, S3)
- Application Layer: Configuration and deployment
Example: Three-Layer Architecture
// main.tf
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.3.0/24", "10.0.4.0/24"]
}
module "database" {
source = "./modules/database"
subnet_ids = module.network.private_subnet_ids
security_group_id = module.network.database_sg_id
engine = "mysql"
instance_class = "db.t3.small"
}
module "web_servers" {
source = "./modules/web_servers"
subnet_ids = module.network.public_subnet_ids
security_group_id = module.network.web_sg_id
instance_type = "t3.micro"
database_endpoint = module.database.endpoint
}
This example shows a layered approach where:
- The network module creates the foundation
- The database module builds on the network
- The web servers module uses both the network and database
Aggregation of Modules
Aggregation combines multiple modules at the same level to create a complete system.
Example: Microservices Architecture
// main.tf
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
}
module "auth_service" {
source = "./modules/microservice"
name = "auth"
subnet_ids = module.network.private_subnet_ids
port = 8080
}
module "user_service" {
source = "./modules/microservice"
name = "user"
subnet_ids = module.network.private_subnet_ids
port = 8081
}
module "payment_service" {
source = "./modules/microservice"
name = "payment"
subnet_ids = module.network.private_subnet_ids
port = 8082
}
module "api_gateway" {
source = "./modules/api_gateway"
services = {
auth = module.auth_service.endpoint
user = module.user_service.endpoint
payment = module.payment_service.endpoint
}
}
This aggregation approach creates a system of microservices that all operate at the same level but work together.
Module Composition Patterns
Let's explore some common patterns for effective module composition:
1. Resource Grouping Pattern
Group related resources into a single module:
module "vpc" {
source = "./modules/vpc"
// VPC-related parameters
}
module "rds" {
source = "./modules/rds"
// RDS-related parameters
}
module "ec2" {
source = "./modules/ec2"
// EC2-related parameters
}
2. Facade Pattern
Create a simpler interface to complex module combinations:
module "wordpress" {
source = "./modules/wordpress"
domain_name = "example.com"
environment = "production"
# The wordpress module handles all the complexity internally
# including database, web server, caching, etc.
}
3. Composition through Outputs
Use outputs from one module as inputs to another:
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
}
module "web_cluster" {
source = "./modules/web_cluster"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnet_ids
}
Best Practices for Module Composition
1. Follow the Single Responsibility Principle
Each module should have a single, well-defined purpose:
# Good: Separate modules with clear responsibilities
module "vpc" { /* ... */ }
module "database" { /* ... */ }
module "web_servers" { /* ... */ }
# Avoid: One mega-module that does everything
module "entire_infrastructure" { /* ... */ }
2. Design for Reusability
Create modules that can be used in multiple contexts:
# Reusable module
module "s3_bucket" {
source = "./modules/s3_bucket"
bucket_name = "app-assets"
force_destroy = true
tags = {
Environment = "production"
Project = "web-app"
}
}
# Same module, different context
module "logs_bucket" {
source = "./modules/s3_bucket"
bucket_name = "app-logs"
force_destroy = false
tags = {
Environment = "production"
Project = "logging"
}
}
3. Use Variables for Configuration
Make modules configurable through variables:
# modules/web_server/variables.tf
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "ami_id" {
description = "AMI ID to use for the instance"
type = string
}
variable "subnet_id" {
description = "Subnet ID where the instance will be created"
type = string
}
4. Expose Necessary Outputs
Make sure modules expose the outputs that other modules need:
# modules/database/outputs.tf
output "endpoint" {
description = "The connection endpoint for the database"
value = aws_db_instance.this.endpoint
}
output "port" {
description = "The port the database is listening on"
value = aws_db_instance.this.port
}
output "name" {
description = "The name of the database"
value = aws_db_instance.this.name
}
output "username" {
description = "The master username for the database"
value = aws_db_instance.this.username
sensitive = true
}
5. Keep Module Interfaces Stable
Avoid making breaking changes to module interfaces:
# Good: Add new optional variables with defaults
variable "enable_logging" {
description = "Whether to enable logging"
type = bool
default = false # Default maintains compatibility
}
# Avoid: Changing existing variable types or removing variables
# This would break existing code that uses the module
Real-World Example: E-commerce Platform
Let's look at a more comprehensive example of module composition for an e-commerce platform:
Here's how this might look in Terraform code:
# main.tf
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.3.0/24", "10.0.4.0/24"]
}
module "database" {
source = "./modules/database"
engine = "mysql"
instance_class = "db.t3.medium"
subnet_ids = module.network.private_subnet_ids
vpc_security_group_ids = [module.network.database_sg_id]
}
module "cache" {
source = "./modules/cache"
engine = "redis"
node_type = "cache.t3.small"
subnet_ids = module.network.private_subnet_ids
security_group_ids = [module.network.cache_sg_id]
}
module "web_tier" {
source = "./modules/web_tier"
vpc_id = module.network.vpc_id
subnet_ids = module.network.public_subnet_ids
security_group_id = module.network.web_sg_id
instance_type = "t3.small"
min_size = 2
max_size = 10
depends_on = [module.database, module.cache]
}
module "api_tier" {
source = "./modules/api_tier"
database_endpoint = module.database.endpoint
cache_endpoint = module.cache.endpoint
subnet_ids = module.network.private_subnet_ids
security_group_id = module.network.api_sg_id
depends_on = [module.database, module.cache]
}
module "search" {
source = "./modules/search"
domain_name = "ecommerce-search"
instance_type = "t3.small.elasticsearch"
instance_count = 3
subnet_ids = module.network.private_subnet_ids
security_group_id = module.network.search_sg_id
}
This example demonstrates how a complex system can be broken down into manageable, purpose-specific modules that work together to create a complete infrastructure.
Common Challenges and Solutions
Managing Module Dependencies
Challenge: Ensuring modules are created in the correct order.
Solution: Use depends_on
to explicitly define dependencies:
module "web_tier" {
source = "./modules/web_tier"
# Configuration...
depends_on = [module.database, module.cache]
}
Handling Shared State
Challenge: Multiple modules need access to the same state.
Solution: Pass outputs from one module to others:
module "network" {
source = "./modules/network"
# Configuration...
}
module "database" {
source = "./modules/database"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
# Other configuration...
}
Module Versioning
Challenge: Ensuring module versions are compatible.
Solution: Use version constraints in module sources:
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "~> 3.0" # Compatible with 3.x but not 4.x
# Configuration...
}
Advanced Concepts
Dynamic Module Generation
You can dynamically create multiple instances of a module using for_each
:
locals {
services = {
api = {
instance_type = "t3.small"
port = 8080
}
web = {
instance_type = "t3.medium"
port = 80
}
worker = {
instance_type = "t3.large"
port = 8081
}
}
}
module "service" {
source = "./modules/service"
for_each = local.services
name = each.key
instance_type = each.value.instance_type
port = each.value.port
subnet_ids = module.network.private_subnet_ids
}
Conditional Module Usage
You can conditionally include modules based on variables:
variable "enable_monitoring" {
description = "Whether to enable monitoring"
type = bool
default = true
}
module "monitoring" {
source = "./modules/monitoring"
count = var.enable_monitoring ? 1 : 0
# Configuration...
}
Module Providers
You can pass provider configurations to modules:
provider "aws" {
region = "us-west-1"
alias = "west"
}
provider "aws" {
region = "us-east-1"
alias = "east"
}
module "west_vpc" {
source = "./modules/vpc"
providers = {
aws = aws.west
}
# Configuration...
}
module "east_vpc" {
source = "./modules/vpc"
providers = {
aws = aws.east
}
# Configuration...
}
Summary
Module composition is a powerful technique for organizing and managing complex Terraform configurations. By understanding the different approaches to module composition—nesting, layering, and aggregation—you can create infrastructure that is both maintainable and scalable.
Remember these key points:
- Follow the Single Responsibility Principle for each module 2
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)