VOOZH about

URL: https://dev.to/instadevops/terraform-modules-done-right-mono-repo-versioning-and-registry-patterns-34g6

⇱ Terraform Modules Done Right: Mono-Repo, Versioning, and Registry Patterns - DEV Community


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.