Introduction
As your Terraform codebase grows beyond a handful of resources, you inevitably face a structural decision: how do you organize reusable infrastructure components so that multiple teams can collaborate without stepping on each other's toes?
The answer is Terraform modules. But writing a module is easy - organizing, versioning, and distributing them at scale is where most teams struggle. Poorly structured modules lead to copy-paste drift, version conflicts, and infrastructure that nobody fully understands.
In this guide, we will walk through battle-tested patterns for organizing Terraform modules, choosing between mono-repo and multi-repo strategies, leveraging module registries, and implementing versioning that actually works in production.
What Makes a Good Terraform Module
Before discussing organization, let us establish what a well-designed module looks like. A good Terraform module follows these principles:
Single responsibility. Each module should manage one logical piece of infrastructure. A VPC module should not also create RDS instances.
Sensible defaults with full override capability. Provide defaults that work for 80% of use cases, but allow every significant parameter to be overridden:
variable "instance_type" {
description = "EC2 instance type for the application servers"
type = string
default = "t3.medium"
}
variable "enable_enhanced_monitoring" {
description = "Enable enhanced monitoring with 60-second granularity"
type = bool
default = true
}
variable "tags" {
description = "Additional tags to apply to all resources"
type = map(string)
default = {}
}
Clear input/output contracts. Every variable should have a description, type constraint, and validation where appropriate. Every output that downstream consumers need should be explicitly exported:
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block."
}
}
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
Minimal provider assumptions. Do not hardcode provider configurations inside modules. Let the caller configure the provider:
# Bad - hardcoded provider in module
provider "aws" {
region = "us-east-1"
}
# Good - module relies on caller's provider configuration
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
Mono-Repo vs Multi-Repo: Choosing the Right Strategy
This is the most debated structural decision in Terraform module management. Both approaches have legitimate trade-offs.
Mono-Repo Pattern
All modules live in a single repository with a directory structure like:
terraform-modules/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ ├── ecs-service/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ ├── rds-postgres/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ └── s3-bucket/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── README.md
├── examples/
│ ├── complete-vpc/
│ └── ecs-with-rds/
├── tests/
│ ├── vpc_test.go
│ └── ecs_test.go
└── .github/
└── workflows/
└── test.yml
Advantages:
- Atomic cross-module changes in a single PR
- Unified CI/CD pipeline for testing
- Easier to maintain consistency across modules
- Simpler onboarding for new team members
- One place to search for all infrastructure patterns
Disadvantages:
- Git tags version the entire repo, not individual modules
- Large repos can slow down CI runs
- Permission boundaries are harder (everyone can see everything)
When to use: Teams under 20 engineers, organizations with fewer than 30 modules, or when most modules are tightly coupled.
Multi-Repo Pattern
Each module gets its own repository:
terraform-module-vpc/ (v2.3.1)
terraform-module-ecs-service/ (v1.8.0)
terraform-module-rds-postgres/ (v3.1.0)
terraform-module-s3-bucket/ (v1.2.4)
Advantages:
- Independent versioning per module using Git tags
- Fine-grained access control per repository
- Smaller, focused CI/CD pipelines
- Clear ownership boundaries
Disadvantages:
- Cross-module changes require multiple PRs
- Dependency management becomes complex
- More repositories to maintain
- Harder to ensure consistency
When to use: Organizations with 50+ modules, multiple platform teams owning different modules, or strict compliance requirements needing audit trails per component.
The Hybrid Approach
Many mature organizations use a hybrid: a mono-repo for foundational modules maintained by the platform team, with separate repos for domain-specific modules owned by product teams:
platform-terraform-modules/ # Platform team owns VPC, IAM, networking
├── modules/vpc/
├── modules/iam-roles/
└── modules/cloudfront/
team-payments-terraform/ # Payments team owns their service modules
├── modules/payment-service/
└── modules/fraud-detection/
Module Versioning Strategies
Versioning is where most Terraform setups break down. Without proper versioning, a module change can silently break every environment that references it.
Semantic Versioning
Follow semver strictly for modules:
- MAJOR (v2.0.0): Breaking changes (removed variables, renamed resources that force replacement)
- MINOR (v1.3.0): New features, new optional variables with defaults
- PATCH (v1.2.1): Bug fixes, documentation updates
Pinning Module Versions
Always pin module versions in your root configurations:
# Good - pinned to exact version
module "vpc" {
source = "git::https://github.com/yourorg/terraform-modules.git//modules/vpc?ref=v2.3.1"
}
# Acceptable - pinned to minor version range
module "vpc" {
source = "app.terraform.io/yourorg/vpc/aws"
version = "~> 2.3"
}
# Bad - no version pin, uses latest
module "vpc" {
source = "git::https://github.com/yourorg/terraform-modules.git//modules/vpc"
}
Automated Version Bumping
Use a CI workflow that automatically creates releases when module directories change:
# .github/workflows/release.yml
name: Release Modules
on:
push:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
changed_modules: ${{ steps.changes.outputs.modules }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: changes
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD | grep '^modules/' | cut -d'/' -f2 | sort -u | jq -R -s -c 'split("\n")[:-1]')
echo "modules=$CHANGED" >> $GITHUB_OUTPUT
release:
needs: detect-changes
if: needs.detect-changes.outputs.changed_modules != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
module: ${{ fromJson(needs.detect-changes.outputs.changed_modules) }}
steps:
- uses: actions/checkout@v4
- name: Determine version bump
id: version
run: |
CURRENT=$(git tag -l "modules/${{ matrix.module }}/v*" | sort -V | tail -1)
# Parse commit messages for bump type
if git log --oneline HEAD~1..HEAD | grep -q "BREAKING"; then
BUMP="major"
elif git log --oneline HEAD~1..HEAD | grep -q "feat"; then
BUMP="minor"
else
BUMP="patch"
fi
echo "bump=$BUMP" >> $GITHUB_OUTPUT
- name: Create release tag
run: |
# Bump version and create tag
git tag "modules/${{ matrix.module }}/v${NEW_VERSION}"
git push --tags
Using a Private Module Registry
A module registry provides a discoverable, versioned catalog of your organization's modules. You have several options.
Terraform Cloud / HCP Terraform Registry
The simplest option if you are already using Terraform Cloud:
module "vpc" {
source = "app.terraform.io/yourorg/vpc/aws"
version = "2.3.1"
cidr_block = "10.0.0.0/16"
environment = "production"
}
Publishing is automatic when you connect your VCS repository to the registry.
Self-Hosted with Artifactory or S3
For air-gapped or highly regulated environments, you can host modules on S3:
module "vpc" {
source = "s3::https://my-terraform-modules.s3.amazonaws.com/vpc/v2.3.1.zip"
}
Pair this with a CI pipeline that packages and uploads module archives on release:
#!/bin/bash
MODULE=$1
VERSION=$2
cd modules/$MODULE
zip -r "/tmp/${MODULE}-${VERSION}.zip" .
aws s3 cp "/tmp/${MODULE}-${VERSION}.zip" \
"s3://my-terraform-modules/${MODULE}/${VERSION}.zip"
GitHub Releases as a Registry
A lightweight approach using Git tags directly:
module "vpc" {
source = "git::https://github.com/yourorg/terraform-modules.git//modules/vpc?ref=v2.3.1"
}
This works well for smaller organizations and avoids the overhead of a dedicated registry.
Module Testing and Validation
Modules without tests are modules you cannot trust. Here are the layers of testing you should implement.
Static Analysis
Run these on every PR:
# Format check
terraform fmt -check -recursive modules/
# Validation
for dir in modules/*/; do
cd "$dir"
terraform init -backend=false
terraform validate
cd ../..
done
# Security scanning with tfsec
tfsec modules/
# Linting with tflint
tflint --recursive
Integration Testing with Terratest
Write Go tests that actually provision and destroy infrastructure:
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVpcModule(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"cidr_block": "10.99.0.0/16",
"environment": "test",
"name": "terratest-vpc",
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
privateSubnets := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
assert.Equal(t, 3, len(privateSubnets))
}
Example Configurations
Every module should ship with a working example in an examples/ directory:
modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md
└── examples/
├── simple/
│ └── main.tf # Minimal usage
└── complete/
└── main.tf # All features enabled
Module Composition Patterns
Real infrastructure is built by composing modules together. Here are patterns that work well at scale.
The Root Module Pattern
Create environment-specific root modules that compose shared modules:
# environments/production/main.tf
module "network" {
source = "app.terraform.io/yourorg/vpc/aws"
version = "2.3.1"
cidr_block = "10.0.0.0/16"
availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
environment = "production"
}
module "database" {
source = "app.terraform.io/yourorg/rds-postgres/aws"
version = "3.1.0"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
instance_class = "db.r6g.xlarge"
multi_az = true
environment = "production"
}
module "application" {
source = "app.terraform.io/yourorg/ecs-service/aws"
version = "1.8.0"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
lb_target_group = module.network.alb_target_group_arn
database_endpoint = module.database.endpoint
desired_count = 6
environment = "production"
}
The Terragrunt DRY Pattern
For organizations managing many environments, Terragrunt eliminates repetition:
# terragrunt.hcl (root)
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "myorg-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
# environments/production/vpc/terragrunt.hcl
terraform {
source = "git::https://github.com/yourorg/terraform-modules.git//modules/vpc?ref=v2.3.1"
}
inputs = {
cidr_block = "10.0.0.0/16"
environment = "production"
}
Common Pitfalls and How to Avoid Them
Over-abstracting too early. Do not create a module until you have written the same Terraform code at least twice. Premature abstraction creates rigid modules that fight against real requirements.
Nested modules more than two levels deep. Module A calls module B which calls module C is already hard to debug. Keep your module hierarchy shallow.
Not using moved blocks during refactors. When restructuring modules, use moved blocks to prevent Terraform from destroying and recreating resources:
moved {
from = aws_instance.web
to = module.application.aws_instance.web
}
Ignoring module documentation. Every module should have a README generated by terraform-docs:
# Generate docs automatically
terraform-docs markdown table modules/vpc/ > modules/vpc/README.md
Storing secrets in tfvars files. Use a secrets manager and data sources instead of committing sensitive values.
Need Help with Your DevOps?
Building and maintaining a well-structured Terraform module library takes experience. At InstaDevOps, we help startups and growing teams implement production-grade Infrastructure as Code from day one - so you can ship infrastructure changes with the same confidence as application code.
Plans start at $2,999/mo for a dedicated fractional DevOps engineer.
Book a free 15-minute consultation to discuss your Terraform architecture.
Top comments (0)