Terraform modules turn infrastructure code from scripts into libraries. Here’s how to design them well.
Module Structure#
Basic Module#
variables.tf#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| variable "name" {
description = "Name prefix for resources"
type = string
}
variable "cidr_block" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "Availability zones"
type = list(string)
}
variable "private_subnets" {
description = "Private subnet CIDR blocks"
type = list(string)
default = []
}
variable "public_subnets" {
description = "Public subnet CIDR blocks"
type = list(string)
default = []
}
variable "tags" {
description = "Tags to apply to resources"
type = map(string)
default = {}
}
|
main.tf#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = var.name
})
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.azs[count.index % length(var.azs)]
tags = merge(var.tags, {
Name = "${var.name}-private-${count.index + 1}"
Type = "private"
})
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.azs[count.index % length(var.azs)]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${count.index + 1}"
Type = "public"
})
}
|
outputs.tf#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| output "vpc_id" {
description = "VPC ID"
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "Private subnet IDs"
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
description = "Public subnet IDs"
value = aws_subnet.public[*].id
}
output "vpc_cidr_block" {
description = "VPC CIDR block"
value = aws_vpc.this.cidr_block
}
|
versions.tf#
1
2
3
4
5
6
7
8
9
10
| terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0"
}
}
}
|
Using Modules#
Local Module#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| module "vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "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"]
tags = {
Environment = "production"
Project = "myapp"
}
}
# Use outputs
resource "aws_instance" "app" {
subnet_id = module.vpc.private_subnet_ids[0]
# ...
}
|
Registry Module#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
}
|
Git Module#
1
2
3
4
| module "vpc" {
source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v1.2.0"
# ...
}
|
Variable Validation#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
validation {
condition = can(regex("^t3\\.", var.instance_type))
error_message = "Instance type must be t3 family."
}
}
variable "cidr_block" {
description = "CIDR block"
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
|
Complex Types#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| variable "services" {
description = "Service configurations"
type = list(object({
name = string
port = number
health_path = optional(string, "/health")
replicas = optional(number, 1)
environment = optional(map(string), {})
}))
}
# Usage
services = [
{
name = "api"
port = 8080
replicas = 3
environment = {
LOG_LEVEL = "info"
}
},
{
name = "worker"
port = 9090
}
]
|
Conditional Resources#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| variable "create_nat_gateway" {
description = "Create NAT gateway"
type = bool
default = true
}
resource "aws_nat_gateway" "this" {
count = var.create_nat_gateway ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
}
resource "aws_eip" "nat" {
count = var.create_nat_gateway ? 1 : 0
domain = "vpc"
}
|
Dynamic Blocks#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| variable "ingress_rules" {
description = "Ingress rules"
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
}))
default = []
}
resource "aws_security_group" "this" {
name = var.name
description = var.description
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
|
Module Composition#
Root Module#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # main.tf
module "vpc" {
source = "./modules/vpc"
# ...
}
module "security_groups" {
source = "./modules/security-groups"
vpc_id = module.vpc.vpc_id
# ...
}
module "ecs_cluster" {
source = "./modules/ecs-cluster"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
security_group_ids = [module.security_groups.app_sg_id]
# ...
}
|
Shared Data#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # data.tf - Shared data sources
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.name
common_tags = {
Environment = var.environment
Project = var.project
ManagedBy = "terraform"
}
}
|
For_each vs Count#
Count (Index-based)#
1
2
3
4
5
| # Problematic: Adding/removing items shifts indices
resource "aws_subnet" "private" {
count = length(var.private_subnets)
cidr_block = var.private_subnets[count.index]
}
|
For_each (Key-based)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| # Better: Keys are stable
variable "subnets" {
type = map(object({
cidr_block = string
az = string
}))
}
resource "aws_subnet" "private" {
for_each = var.subnets
cidr_block = each.value.cidr_block
availability_zone = each.value.az
tags = {
Name = each.key
}
}
# Usage
subnets = {
"private-1a" = { cidr_block = "10.0.1.0/24", az = "us-east-1a" }
"private-1b" = { cidr_block = "10.0.2.0/24", az = "us-east-1b" }
}
|
Sensitive Values#
1
2
3
4
5
6
7
8
9
10
11
| variable "database_password" {
description = "Database password"
type = string
sensitive = true
}
output "connection_string" {
description = "Database connection string"
value = "postgres://user:${var.database_password}@${aws_db_instance.this.endpoint}/db"
sensitive = true
}
|
Module Testing#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # tests/vpc_test.tftest.hcl
run "vpc_creates_successfully" {
command = plan
variables {
name = "test-vpc"
cidr_block = "10.0.0.0/16"
azs = ["us-east-1a"]
}
assert {
condition = aws_vpc.this.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR block incorrect"
}
}
|
Run tests:
Documentation#
README.md#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # VPC Module
Creates a VPC with public and private subnets.
## Usage
```hcl
module "vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
}
|
| Name | Description | Type | Default | Required |
|---|
| name | Name prefix | string | - | yes |
| cidr_block | VPC CIDR | string | “10.0.0.0/16” | no |
Outputs#
| Name | Description |
|---|
| vpc_id | VPC ID |
| private_subnet_ids | Private subnet IDs |
Best Practices#
- Pin versions: Always specify provider and module versions
- Use for_each over count: Stable keys prevent recreation
- Validate inputs: Catch errors early with validation blocks
- Output everything useful: Make modules composable
- Document thoroughly: Include examples and descriptions
- Keep modules focused: Single responsibility principle
- Use locals: Name complex expressions
- Sensitive handling: Mark secrets as sensitive
Good modules are like good functions: clear inputs, predictable outputs, and a single purpose.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.