Skip to main content

Command Palette

Search for a command to run...

Building a Reusable Terraform Module Library for Azure Teams

Published
10 min read
Building a Reusable Terraform Module Library for Azure Teams
J

Executive technology leader responsible for platform reliability, cloud operations, security posture, and enterprise technology risk within an investor-backed fintech environment. I lead technology operations at the intersection of engineering execution, governance, and business outcomes — ensuring platforms are scalable, resilient, and trusted by investors, regulators, and clients.

Currently VP of DevOps at InvestorFlow, where I focus on building board-ready technology operations, strengthening risk and resilience, and shaping long-term platform strategy to support growth and regulatory confidence.

Every Azure team I have worked with eventually reaches the same tipping point. You start with a single Terraform repository, a few modules in a modules/ folder, and life is fine. Then a second team wants the same storage account pattern. Then a third team copies your module, tweaks a tag, and never speaks to you again. Six months later, you have four different versions of what was once the same module, each one drifting in its own direction, and nobody can remember which one is the source of truth.

A shared module library is the answer, but it only works if you treat the modules like software rather than like files on a shared drive. That means versioning, a registry, and a clear pattern for how teams consume them. In this post, I'll walk through how I structure a reusable Terraform module library for Azure teams, how to version the modules properly, the trade-offs between a private registry and GitHub releases, and the consumption patterns that keep everyone happy.

Why a module library is worth the effort

Before getting into the structure, it's worth being honest about the upfront cost. Building a module library takes real engineering time, and the benefits take a few months to show. You are investing in:

  • A single source of truth for how Azure resources should be deployed in your organisation

  • Guardrails baked into the module itself, such as mandatory tags, diagnostic settings, or TLS versions

  • A clear upgrade path when the Azure provider changes behaviour, which it does often

  • Less copy-paste from public examples that may not match your naming or compliance standards

If your team is still running a single Terraform repo with a couple of environments, you probably do not need a module library yet. The moment you have more than one team writing Terraform against the same Azure tenant, it starts to pay for itself.

How to structure the modules

The structure I settle on is one repository per module. It seems heavy at first, but it is the pattern that works with both the Terraform private registry and with GitHub releases, and it gives each module its own lifecycle.

A typical module repository looks like this:

terraform-azurerm-storageaccount/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
├── examples/
│   ├── basic/
│   └── with-private-endpoint/
└── .github/
    └── workflows/
        └── release.yml

A few things to note:

The repository name follows the terraform-<PROVIDER>-<NAME> convention. This is required by the Terraform registry, and it is a useful naming standard even if you pick GitHub releases instead. It also makes every repository in your GitHub organisation instantly recognisable as a module.

The examples/ folder is not decoration. Each example is a working root module that consumes the module under test. They double as documentation, as integration test fixtures, and as the thing you show a new developer when they ask how to use the module.

The versions.tf file pins the provider constraints. Keep these as open as safely possible, using ~> 4.0 rather than = 4.12.1. A module that over-pins its provider will make life miserable for every consumer.

Versioning the modules

Modules in a shared library have to follow semantic versioning. There is no way around this. Every change you publish has to be tagged as a new version, and the version number has to tell consumers what to expect.

I use the standard MAJOR.MINOR.PATCH rules:

  • Patch (1.2.0 to 1.2.1): a bug fix, documentation change, or a refactor that produces an identical plan

  • Minor (1.2.0 to 1.3.0): a new optional variable, a new output, or new functionality that does not change existing behaviour

  • Major (1.2.0 to 2.0.0): a breaking change, a renamed variable, a removed output, or anything that will cause a non-empty plan on an existing deployment

The breaking change rule is the one people get wrong most often. Renaming an input variable from name to storage_account_name is a major version bump, even if the internal resource is identical, because every consumer has to update their module block. Adding a new resource that will appear in the plan for existing consumers is also a major change, because it changes their plan output.

In Git, the tags follow the vX.Y.Z format. The Terraform registry requires the v prefix.

git tag v1.3.0
git push origin v1.3.0

Picking a registry: HCP Terraform vs GitHub releases

Once you have tagged modules, you need somewhere for consumers to pull them from. There are two mature options for a private library.

HCP Terraform private registry

If your organisation is already on HCP Terraform, the private registry is the path of least resistance. You connect it to your VCS provider, point it at a module repository, and HCP Terraform automatically publishes any tag that matches vX.Y.Z. Consumers reference modules with a clean registry-style source:

module "storage" {
  source  = "app.terraform.io/my-org/storageaccount/azurerm"
  version = "~> 1.3"

  name                = "stmyappprd001"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
}

The advantages here are real. The version constraint supports operators like ~> 1.3, so consumers can pick up minor and patch updates without changing their code. The registry also renders your module's README and shows the available versions in the UI, which gives you a lightweight documentation site for free.

The downside is that you need HCP Terraform for the consumers, not just the registry. A module block that sources from app.terraform.io will not resolve from a local CLI without a valid HCP Terraform token, and public modules can be referenced from anywhere, but private ones cannot. If your teams are running Terraform from GitHub Actions or Azure Pipelines without HCP Terraform involvement, this becomes awkward.

GitHub releases

If you are not on HCP Terraform, or if you want a registry-independent option, Git-based sources work everywhere Terraform runs. The source reference looks like this:

module "storage" {
  source = "git::https://github.com/my-org/terraform-azurerm-storageaccount.git?ref=v1.3.0"

  name                = "stmyappprd001"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
}

Every version is just a Git tag. Authentication uses whatever Git credentials the machine already has, such as an SSH key, a personal access token, or a GitHub App token in CI.

The big drawback is that Git sources do not support the version argument or constraint operators. You are pinning to a specific tag every time. If you want to adopt a new minor release across twenty consumers, that is twenty pull requests. Tools like Dependabot and Renovate do help here, but they are an extra moving part.

Which one I pick

My default is the HCP Terraform private registry if the organisation is already on the platform. The version constraint support and the rendered documentation make it worth it. If not, GitHub releases with Renovate configured to raise pull requests for new tags gets you most of the same benefits without the platform dependency. What I would avoid is using a Git source without any automation around version bumps, because consumers will end up stuck on an old tag forever.

Consumption patterns for teams

How teams consume the modules matters as much as how you publish them. There are three patterns I see work well, and one that always causes problems.

Pin to a minor version range

For modules on the HCP Terraform registry, pinning to a minor version range gives you the best balance of stability and upgrade velocity.

module "storage" {
  source  = "app.terraform.io/my-org/storageaccount/azurerm"
  version = "~> 1.3"
  # ... inputs
}

This accepts any 1.3.x release automatically. A patch gets picked up on the next terraform init -upgrade, but a minor or major bump has to be explicit. I recommend terraform init -upgrade runs on a schedule in CI so that patches flow in without needing a pull request for every one.

Pin exact for Git sources

With Git sources, you have no choice but to pin exactly. Make this explicit in your conventions, and use a tool like Renovate to keep the pins moving forward.

{
  "extends": ["config:base"],
  "terraform": {
    "enabled": true
  }
}

A renovate.json in the consuming repository will raise a pull request every time a new tag appears in the module repository, which is about as close as Git sources get to the registry experience.

A "starter" pattern for new projects

A useful thing to publish alongside the modules is a starter repository or a small set of example root modules. When a team is spinning up something new, they should not be copying snippets out of README files. Instead, give them a working template that already consumes your modules correctly and includes the pipeline configuration.

The anti-pattern: sourcing from main

The one pattern that reliably causes outages is sourcing a module from a branch rather than a tag:

module "storage" {
  source = "git::https://github.com/my-org/terraform-azurerm-storageaccount.git?ref=main"
}

This looks convenient during development, but it means that anyone running a plan picks up whatever the latest commit to main happens to be. A breaking change that lands on Monday afternoon will surprise the person doing a production apply on Tuesday morning. Ban this pattern in code review, and enforce it with a policy check if you can.

Testing changes before they hit consumers

A module library only works if consumers can trust that a new version behaves. Before publishing, I want a green CI run that does at least the following for every pull request:

  1. terraform fmt -check and terraform validate on the module itself

  2. A plan of each example under examples/ against a real subscription

  3. A static analysis pass using something like tflint or checkov

  4. If the change is meaningful, an actual apply and destroy of one of the examples

The apply step is the one people skip and it is the one that catches the most real bugs. Plans pass all the time on changes that would fail on apply, especially anything involving lifecycle blocks, for_each keys, or interactions with existing resources.

For the release itself, I use a GitHub Actions workflow triggered on tag push. It runs the full test suite, creates a GitHub Release with auto-generated notes, and if HCP Terraform is connected, the private registry picks up the tag automatically.

My thoughts on module libraries

A module library is one of those investments that feels oversized in the first month and undersized in the sixth. Once it is in place, your Azure Terraform stops being a pile of copy-pasted snippets and starts being a set of composable, versioned building blocks. Teams stop asking how to deploy a storage account and start asking what to deploy.

The choice of registry matters less than the discipline around versioning and the quality of the modules themselves. A badly-written module on HCP Terraform will cause more pain than a well-written module behind a Git tag. Start with two or three modules for the Azure resources your teams deploy most often, enforce semver from day one, and grow the library based on real demand rather than speculation.

%buymeacoffe-butyellow