DEV Community

InstaDevOps
InstaDevOps

Posted on • Originally published at instadevops.com

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

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     = {}
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)