DevOpsApril 13, 202612 min read

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

Share:

Free DevOps Audit Checklist

Get our comprehensive checklist to identify gaps in your infrastructure, security, and deployment processes

Instant delivery. No spam, ever.

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"
    }
  }
}

Need DevOps help?

InstaDevOps provides expert DevOps engineering starting at $2,999/mo. Skip the hiring headache.

Book a free 15-min call →

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.

Ready to Transform Your DevOps?

Get started with InstaDevOps and experience world-class DevOps services.

Book a Free Call

Never Miss an Update

Get the latest DevOps insights, tutorials, and best practices delivered straight to your inbox. Join 500+ engineers leveling up their DevOps skills.

We respect your privacy. Unsubscribe at any time. No spam, ever.