Introduction#
Terraform modules are reusable units of infrastructure configuration. They allow teams to standardize how resources are created and prevent duplication across environments. This post covers module structure, versioning, and patterns that scale in large organizations.
Module Structure#
1
2
3
4
5
6
7
| modules/
└── eks-cluster/
├── main.tf # resource definitions
├── variables.tf # input variables
├── outputs.tf # output values
├── versions.tf # required providers and Terraform version
└── README.md # usage documentation
|
1
2
3
4
5
6
7
8
9
10
| # versions.tf: pin provider versions
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
|
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
| # variables.tf: document every variable
variable "cluster_name" {
type = string
description = "Name of the EKS cluster. Used as a prefix for all related resources."
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,39}$", var.cluster_name))
error_message = "Cluster name must be lowercase alphanumeric and hyphens, 3-40 chars."
}
}
variable "node_groups" {
type = map(object({
instance_types = list(string)
min_size = number
max_size = number
desired_size = number
}))
description = "Map of node group name to configuration."
default = {
general = {
instance_types = ["t3.medium"]
min_size = 1
max_size = 5
desired_size = 2
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
| # outputs.tf: expose values callers need
output "cluster_endpoint" {
description = "EKS cluster API server endpoint."
value = aws_eks_cluster.main.endpoint
}
output "cluster_certificate_authority" {
description = "Base64 encoded certificate authority for the cluster."
value = aws_eks_cluster.main.certificate_authority[0].data
sensitive = true
}
|
Module Versioning#
Pin modules to specific versions to prevent unexpected changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| # Using a module from a Git repository
module "eks" {
source = "git::https://github.com/org/terraform-modules.git//eks-cluster?ref=v2.3.1"
cluster_name = "production"
node_groups = {
general = {
instance_types = ["m5.large"]
min_size = 3
max_size = 20
desired_size = 5
}
}
}
# Using a module from Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0" # pin to exact version
name = "production-vpc"
cidr = "10.0.0.0/16"
}
|
Passing Data Between Modules#
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
| # root/main.tf
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
name = var.environment
}
module "eks" {
source = "./modules/eks-cluster"
cluster_name = "${var.environment}-cluster"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
# Explicit dependency — not needed if using output references,
# but sometimes useful for non-obvious dependencies
depends_on = [module.vpc]
}
module "rds" {
source = "./modules/rds"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.database_subnet_ids
allowed_sg_ids = [module.eks.node_security_group_id]
}
|
For Each and Dynamic Modules#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| # Create multiple similar resources using for_each
variable "services" {
type = map(object({
port = number
health_path = string
min_instances = number
}))
}
module "ecs_service" {
for_each = var.services
source = "./modules/ecs-service"
name = each.key
port = each.value.port
health_path = each.value.health_path
min_instances = each.value.min_instances
}
# Access outputs from for_each modules
output "service_urls" {
value = { for k, v in module.ecs_service : k => v.url }
}
|
Testing Modules#
1
2
| # Terratest: Go-based testing for Terraform modules
# test/eks_cluster_test.go
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestEKSCluster(t *testing.T) {
opts := &terraform.Options{
TerraformDir: "../examples/complete",
Vars: map[string]interface{}{
"cluster_name": "test-cluster",
"region": "us-east-1",
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
clusterEndpoint := terraform.Output(t, opts, "cluster_endpoint")
assert.Contains(t, clusterEndpoint, "amazonaws.com")
}
|
Module Design Principles#
- Single responsibility: a module should do one thing well. A module creating a VPC, EKS cluster, and RDS in one block is too broad.
- Sensible defaults: variables should have defaults where appropriate so callers can be concise.
- No hardcoded values: regions, account IDs, naming conventions should all be variables.
- Semantic outputs: expose everything a caller might need; avoid requiring callers to use
data sources to find values your module already has.
- Idempotency: applying the same configuration twice should produce no changes.
Conclusion#
Modules encode your organization’s infrastructure standards. Version them in Git with tags and reference specific versions in root modules. Use for_each for creating multiple similar resources. Test modules with Terratest before publishing. The discipline of pinning module versions and keeping modules focused prevents the common problem of a module update breaking many environments simultaneously.