Organizing Code with Terraform Modules
Learn how to create reusable infrastructure components with modules and use community modules
TLDR: Modules are reusable Terraform configurations. Put related resources in a directory with variables and outputs, then call that module multiple times with different inputs. Use modules to avoid repetition, enforce standards, and share infrastructure patterns across teams.
As your Terraform configurations grow, modules help organize code into reusable components. Instead of copying infrastructure definitions, package them as modules and use them wherever needed.
What Are Modules?
Every Terraform configuration is a module. The files in your working directory form the "root module". When you create subdirectories with their own configurations, those become "child modules" that the root module can call.
A module is just a directory containing:
- Resource definitions (
.tffiles) - Variable declarations (inputs)
- Output declarations (return values)
- Optionally: documentation, examples, tests
project/
├── main.tf # Root module
├── variables.tf
├── outputs.tf
└── modules/ # Child modules
├── vpc/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── web_server/
├── main.tf
├── variables.tf
└── outputs.tf
Creating Your First Module
Let's create a module for a web server with a security group.
Create modules/web_server/main.tf:
# modules/web_server/main.tf
resource "aws_security_group" "web" {
name = "${var.name}-sg"
description = "Security group for ${var.name}"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP from anywhere"
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS from anywhere"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "All outbound traffic"
}
tags = merge(
var.tags,
{
Name = "${var.name}-sg"
}
)
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = var.user_data
root_block_device {
volume_size = var.disk_size
}
tags = merge(
var.tags,
{
Name = var.name
}
)
}
Define inputs in modules/web_server/variables.tf:
# modules/web_server/variables.tf
variable "name" {
description = "Name for the web server"
type = string
}
variable "vpc_id" {
description = "VPC ID where resources will be created"
type = string
}
variable "subnet_id" {
description = "Subnet ID for the instance"
type = string
}
variable "ami_id" {
description = "AMI ID for the instance"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "disk_size" {
description = "Root disk size in GB"
type = number
default = 20
}
variable "user_data" {
description = "User data script"
type = string
default = ""
}
variable "tags" {
description = "Tags to apply to resources"
type = map(string)
default = {}
}
Define outputs in modules/web_server/outputs.tf:
# modules/web_server/outputs.tf
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "instance_public_ip" {
description = "Public IP address"
value = aws_instance.web.public_ip
}
output "instance_private_ip" {
description = "Private IP address"
value = aws_instance.web.private_ip
}
output "security_group_id" {
description = "ID of the security group"
value = aws_security_group.web.id
}
Using Modules
Now use this module in your root configuration:
# main.tf
module "web_server" {
source = "./modules/web_server"
name = "production-web"
vpc_id = aws_vpc.main.id
subnet_id = aws_subnet.public.id
ami_id = data.aws_ami.ubuntu.id
instance_type = "t3.small"
disk_size = 30
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
EOF
tags = {
Environment = "production"
Project = "web-app"
}
}
# Access module outputs
output "web_server_ip" {
value = module.web_server.instance_public_ip
}
The source argument tells Terraform where to find the module. It can be:
- Local path:
./modules/web_server - Git repository:
git::https://github.com/user/terraform-modules.git//web_server?ref=v1.0.0 - Terraform Registry:
terraform-aws-modules/vpc/aws(shown later) - HTTP URL:
https://example.com/terraform-modules/web_server.zip
When you run terraform init, Terraform downloads and caches modules from remote sources.
Multiple Module Instances
Use the same module multiple times with different configurations:
module "web_server_1" {
source = "./modules/web_server"
name = "web-1"
vpc_id = aws_vpc.main.id
subnet_id = aws_subnet.public_a.id
ami_id = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Environment = "dev"
Role = "web"
}
}
module "web_server_2" {
source = "./modules/web_server"
name = "web-2"
vpc_id = aws_vpc.main.id
subnet_id = aws_subnet.public_b.id
ami_id = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Environment = "dev"
Role = "web"
}
}
module "api_server" {
source = "./modules/web_server"
name = "api"
vpc_id = aws_vpc.main.id
subnet_id = aws_subnet.private.id
ami_id = data.aws_ami.ubuntu.id
instance_type = "t3.small"
tags = {
Environment = "dev"
Role = "api"
}
}
Each module call creates its own set of resources.
Module Count and For_Each
Create multiple instances of an entire module:
variable "web_servers" {
type = map(object({
subnet_id = string
instance_type = string
}))
default = {
web-1 = {
subnet_id = "subnet-abc123"
instance_type = "t3.micro"
}
web-2 = {
subnet_id = "subnet-def456"
instance_type = "t3.micro"
}
api = {
subnet_id = "subnet-ghi789"
instance_type = "t3.small"
}
}
}
module "servers" {
for_each = var.web_servers
source = "./modules/web_server"
name = each.key
vpc_id = aws_vpc.main.id
subnet_id = each.value.subnet_id
ami_id = data.aws_ami.ubuntu.id
instance_type = each.value.instance_type
tags = {
Environment = "production"
}
}
# Access a specific module's output
output "web_1_ip" {
value = module.servers["web-1"].instance_public_ip
}
# Access all module outputs
output "all_server_ips" {
value = { for k, v in module.servers : k => v.instance_public_ip }
}
Using Public Modules
The Terraform Registry hosts thousands of community modules. Let's use the popular AWS VPC module:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2"
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 = false
tags = {
Environment = "dev"
Project = "web-app"
}
}
# Use module outputs
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnets[0]
vpc_security_group_ids = [aws_security_group.web.id]
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = module.vpc.vpc_id
# ... rules ...
}
Always pin module versions to avoid unexpected changes:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # Allow 5.x but not 6.0
# ...
}
Find modules at registry.terraform.io.
Nested Modules
Modules can call other modules. Create a module for a complete application:
# modules/web_app/main.tf
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr
name = "${var.name}-vpc"
tags = var.tags
}
module "web_servers" {
source = "../web_server"
for_each = var.server_configs
name = "${var.name}-${each.key}"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnet_ids[0]
ami_id = var.ami_id
instance_type = each.value.instance_type
tags = merge(var.tags, {
Server = each.key
})
}
module "database" {
source = "../rds"
name = "${var.name}-db"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
tags = var.tags
}
This "web_app" module orchestrates VPC, web server, and database modules.
Module Best Practices
Keep Modules Focused
Each module should have a single, clear purpose:
- Good:
vpc,web_server,rds_database - Bad:
entire_infrastructure
Small, focused modules are easier to test, understand, and reuse.
Document Your Modules
Add a README.md explaining what the module does, required inputs, and outputs:
# Web Server Module
Creates an EC2 instance with a security group configured for web traffic.
## Usage
\`\`\`hcl
module "web" {
source = "./modules/web_server"
name = "my-web-server"
vpc_id = "vpc-abc123"
subnet_id = "subnet-def456"
ami_id = "ami-0c55b159cbfafe1f0"
}
\`\`\`
## Requirements
- AWS provider >= 5.0
- VPC must already exist
- Subnet must be in the VPC
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| name | Server name | string | - | yes |
| vpc_id | VPC ID | string | - | yes |
| instance_type | Instance type | string | t3.micro | no |
## Outputs
| Name | Description |
|------|-------------|
| instance_id | EC2 instance ID |
| instance_public_ip | Public IP address |
Provide Examples
Include an examples/ directory showing how to use the module:
modules/web_server/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md
└── examples/
├── basic/
│ └── main.tf
└── complete/
└── main.tf
Version Your Modules
If sharing modules across projects, version them with Git tags:
git tag -a v1.0.0 -m "Initial release"
git push --tags
Reference specific versions:
module "web_server" {
source = "git::https://github.com/yourorg/terraform-modules.git//web_server?ref=v1.0.0"
# ...
}
Avoid Hardcoded Values
Make modules configurable with variables instead of hardcoding values:
# Bad - hardcoded
resource "aws_instance" "web" {
instance_type = "t3.micro"
# ...
}
# Good - configurable
resource "aws_instance" "web" {
instance_type = var.instance_type
# ...
}
Use Sensible Defaults
Provide defaults for optional variables:
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "disk_size" {
description = "Root disk size in GB"
type = number
default = 20
validation {
condition = var.disk_size >= 8 && var.disk_size <= 1000
error_message = "Disk size must be between 8 and 1000 GB."
}
}
This makes modules easier to use while still allowing customization.
Practical Example: Complete Application
Here's a realistic multi-module setup:
# Root main.tf
terraform {
required_version = "~> 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# VPC module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2"
name = "${var.project}-${var.environment}-vpc"
cidr = var.vpc_cidr
azs = data.aws_availability_zones.available.names
private_subnets = var.private_subnet_cidrs
public_subnets = var.public_subnet_cidrs
enable_nat_gateway = true
single_nat_gateway = var.environment != "prod"
enable_dns_hostnames = true
tags = local.common_tags
}
# Web server module
module "web_servers" {
source = "./modules/web_server"
for_each = var.web_server_configs
name = "${var.project}-${var.environment}-${each.key}"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnets[each.value.subnet_index]
ami_id = data.aws_ami.ubuntu.id
instance_type = each.value.instance_type
tags = merge(local.common_tags, {
Role = "web"
})
}
# Database module
module "database" {
source = "./modules/rds"
identifier = "${var.project}-${var.environment}-db"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
instance_class = var.environment == "prod" ? "db.t3.medium" : "db.t3.micro"
allocated_storage = var.environment == "prod" ? 100 : 20
allowed_security_groups = [
for server in module.web_servers : server.security_group_id
]
tags = merge(local.common_tags, {
Role = "database"
})
}
# Load balancer module
module "load_balancer" {
source = "./modules/alb"
name = "${var.project}-${var.environment}-alb"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnets
target_instance_ids = [
for server in module.web_servers : server.instance_id
]
tags = merge(local.common_tags, {
Role = "load-balancer"
})
}
# Outputs
output "load_balancer_endpoint" {
description = "Application endpoint"
value = "http://${module.load_balancer.dns_name}"
}
output "web_server_ips" {
description = "Web server IP addresses"
value = { for k, v in module.web_servers : k => v.instance_public_ip }
}
output "database_endpoint" {
description = "Database connection string"
value = module.database.endpoint
sensitive = true
}
This setup demonstrates:
- Using public registry modules (VPC)
- Using custom local modules (web servers, database, load balancer)
- Module for_each for multiple instances
- Passing outputs between modules
- Environment-specific configurations
- Consistent tagging across all modules
Modules are how you scale Terraform from simple configurations to complex, maintainable infrastructure. Next, we'll explore managing multiple environments with the same code.
Found an issue?