Terraform modules are like functions for your infrastructure, letting you package and reuse configurations.
Let’s say you want to spin up an RDS Aurora cluster. Normally, you’d write a bunch of HCL for the aws_rds_cluster and aws_rds_cluster_instance resources. But if you’re doing this in multiple environments (dev, staging, prod) or for different applications, you’ll quickly find yourself copy-pasting and changing small things.
Here’s what a basic RDS Aurora setup might look like in Terraform without a module:
resource "aws_rds_cluster" "main" {
cluster_identifier = "my-app-db"
engine = "aurora-mysql"
engine_version = "8.0.mysql_aurora.3.02.0"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
database_name = "appdb"
master_username = "admin"
master_password = "verysecretpassword" # In reality, use secrets manager or parameter store!
skip_final_snapshot = true
backup_retention_period = 7
preferred_backup_window = "07:00-09:00"
vpc_security_group_ids = ["sg-0123456789abcdef0"]
db_subnet_group_name = "my-db-subnet-group"
}
resource "aws_rds_cluster_instance" "writer" {
identifier = "my-app-db-writer"
cluster_identifier = aws_rds_cluster.main.id
instance_class = "db.r6g.large"
engine = "aurora-mysql"
engine_version = "8.0.mysql_aurora.3.02.0"
publicly_accessible = false
}
resource "aws_rds_cluster_instance" "reader" {
identifier = "my-app-db-reader"
cluster_identifier = aws_rds_cluster.main.id
instance_class = "db.r6g.large"
engine = "aurora-mysql"
engine_version = "8.0.mysql_aurora.3.02.0"
publicly_accessible = false
}
This works, but it’s verbose. Now, let’s turn this into a module.
Module Structure
You’d typically create a directory for your module, say modules/rds-aurora. Inside this directory, you’d have:
main.tf: Contains the resource definitions (aws_rds_cluster,aws_rds_cluster_instance).variables.tf: Defines the input variables for your module (e.g., cluster name, engine version, instance class).outputs.tf: Defines the outputs of your module (e.g., cluster endpoint, ARN).
modules/rds-aurora/main.tf:
resource "aws_rds_cluster" "this" {
cluster_identifier = var.cluster_identifier
engine = var.engine
engine_version = var.engine_version
availability_zones = var.availability_zones
database_name = var.database_name
master_username = var.master_username
master_password = var.master_password
skip_final_snapshot = var.skip_final_snapshot
backup_retention_period = var.backup_retention_period
preferred_backup_window = var.preferred_backup_window
vpc_security_group_ids = var.vpc_security_group_ids
db_subnet_group_name = var.db_subnet_group_name
tags = merge(
{
"ManagedBy" = "Terraform"
"Environment" = var.environment
},
var.tags
)
}
resource "aws_rds_cluster_instance" "this" {
count = var.instance_count
identifier = "${var.cluster_identifier}-instance-${count.index}"
cluster_identifier = aws_rds_cluster.this.id
instance_class = var.instance_class
engine = var.engine
engine_version = var.engine_version
publicly_accessible = false # Keep this false for security
}
modules/rds-aurora/variables.tf:
variable "cluster_identifier" {
description = "The unique identifier for the RDS cluster."
type = string
}
variable "engine" {
description = "The database engine to use (e.g., aurora-mysql, aurora-postgresql)."
type = string
default = "aurora-mysql"
}
variable "engine_version" {
description = "The engine version for the RDS cluster."
type = string
default = "8.0.mysql_aurora.3.02.0" # Example for Aurora MySQL
}
variable "availability_zones" {
description = "A list of Availability Zones for the cluster."
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "database_name" {
description = "The name of the initial database to create."
type = string
default = "appdb"
}
variable "master_username" {
description = "The master username for the database."
type = string
}
variable "master_password" {
description = "The master password for the database. Use a secrets manager for production."
type = string
sensitive = true # Mark as sensitive to prevent exposure in logs
}
variable "skip_final_snapshot" {
description = "Determines whether to skip the final snapshot when the cluster is deleted."
type = bool
default = true
}
variable "backup_retention_period" {
description = "The number of days to retain backups."
type = number
default = 7
}
variable "preferred_backup_window" {
description = "The daily time range during which automated backups are created."
type = string
default = "07:00-09:00"
}
variable "vpc_security_group_ids" {
description = "List of VPC security group IDs to associate with the RDS cluster."
type = list(string)
}
variable "db_subnet_group_name" {
description = "The name of the DB subnet group."
type = string
}
variable "instance_count" {
description = "The number of read replicas to create."
type = number
default = 1 # Default to one writer instance
}
variable "instance_class" {
description = "The instance class for the RDS instances (e.g., db.r6g.large)."
type = string
default = "db.r6g.large"
}
variable "environment" {
description = "The environment name (e.g., dev, staging, prod)."
type = string
}
variable "tags" {
description = "A map of additional tags to assign to the resources."
type = map(string)
default = {}
}
modules/rds-aurora/outputs.tf:
output "cluster_id" {
description = "The RDS cluster identifier."
value = aws_rds_cluster.this.id
}
output "cluster_endpoint" {
description = "The connection endpoint for the RDS cluster."
value = aws_rds_cluster.this.endpoint
}
output "cluster_reader_endpoint" {
description = "The reader endpoint for the RDS cluster."
value = aws_rds_cluster.this.reader_endpoint
}
output "cluster_arn" {
description = "The ARN of the RDS cluster."
value = aws_rds_cluster.this.arn
}
Using the Module
Now, in your root Terraform configuration (or another module), you can call this module like so:
main.tf (in your root directory):
provider "aws" {
region = "us-east-1"
}
# Assume these resources are defined elsewhere or provided as data sources
data "aws_vpc" "main" {
filter {
name = "tag:Name"
values = ["my-vpc"]
}
}
data "aws_subnet_ids" "private" {
vpc_id = data.vpc.main.id
filter {
name = "tag:Tier"
values = ["private"]
}
}
resource "aws_db_subnet_group" "default" {
name = "my-db-subnet-group"
subnet_ids = data.aws_subnet_ids.private.ids
}
resource "aws_security_group" "rds" {
name = "rds-sg"
description = "Allow DB access"
vpc_id = data.vpc.main.id
ingress {
from_port = 3306 # For Aurora MySQL
to_port = 3306
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"] # Example: Allow access from within your VPC
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "rds-sg"
}
}
module "app_db_dev" {
source = "./modules/rds-aurora" # Path to your module
cluster_identifier = "app-db-dev"
environment = "dev"
master_username = "devadmin"
master_password = "devsecret" # Use a more secure method in production
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.default.name
instance_count = 1 # One writer, no readers for dev
engine_version = "8.0.mysql_aurora.3.02.0"
tags = {
Application = "MyApp"
}
}
module "app_db_prod" {
source = "./modules/rds-aurora"
cluster_identifier = "app-db-prod"
environment = "prod"
master_username = "prodadmin"
master_password = "prodverysecret" # Use AWS Secrets Manager!
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.default.name
instance_count = 3 # One writer, two readers for prod
engine_version = "8.0.mysql_aurora.3.02.0"
backup_retention_period = 30
tags = {
Application = "MyApp"
}
}
output "app_db_dev_endpoint" {
value = module.app_db_dev.cluster_endpoint
}
output "app_db_prod_endpoint" {
value = module.app_db_prod.cluster_endpoint
}
This setup allows you to define your RDS Aurora cluster configuration once in the module and then reuse it by simply providing different variable values for each instance. This is the core power of modules: abstraction and reusability.
The most surprising thing about Terraform modules is how they change your mental model from "managing resources" to "managing reusable infrastructure components." You start thinking about the interfaces (variables and outputs) more than the specific AWS API calls.
The next concept you’ll likely encounter is managing module versions and sourcing modules from remote locations like Terraform Registry or Git repositories.