DevOps Infrastructure as Code

Reusable Terraform Modules at Scale on Azure DevOps: Repo-per-Module, Versioning & Composition

Two things break a platform team’s Terraform estate, and both look like success at first. The first is the copy-paste module: a modules/ folder lives inside the application repo, someone needs the same thing in a second repo, so they copy it — and now there are two virtual-network modules that drift apart for two years until a security fix has to be applied to forty subtly-different copies. The second is the everything-monorepo: one giant repo with one giant state file, where a one-line change to a tag on a storage account triggers a plan that touches three hundred resources, takes nine minutes, and shows a diff nobody can review. Both come from the same missing idea: a Terraform module is a versioned, independently-released software artifact, and it deserves the same lifecycle as a library — its own repo, its own CI, its own semantic version tags, and a published interface that consumers pin to. This article is about building exactly that on Azure DevOps, the way a real platform team does it for an enterprise with one Azure DevOps organisation, many projects, a dedicated IaC project that holds the modules, and a shared self-hosted agent fleet that runs every terraform plan.

The reference architecture is concrete. There is an IaC project whose responsibility is Terraform: it holds the modules, with one Git repo per module (tf-module-vnet, tf-module-key-vault, tf-module-mysql-flexible, …), each repo carrying the canonical layout — main.tf, variables.tf, outputs.tf, locals.tf, a handful of .tpl templates, an examples/ folder, and a tests/ folder. Separately there are root configurations (the live “deployments”) that compose those modules by Git source + version ref and apply them against the landing zone — a CAF management-group hierarchy with a Key Vault per scope and a hub-spoke network. State lives in azurerm remote backends, one state file per environment per stack, so Non-Prod and Prod are physically isolated. Every module repo has a module CI pipeline (fmt, validate, tflint, checkov, terraform-docs, a release tagger) and every root config has two apply pipelines — a Non-Prod one that auto-applies to Dev/SIT and a Prod one that is gated. The shared VM Scale Set agents in the hub network run all of it.

By the end you will be able to lay out a module repo that a stranger can consume without reading your code, cut a 2.1.0 tag that a hundred root configs can pin to, write the git::https://…?ref=2.1.0 source line that makes consumption auditable, design the remote-state partitioning that keeps a Prod blast radius small, and build the module-CI and dual apply pipelines on Azure DevOps that enforce all of it. This is the IaC slice of a six-part enterprise CI/CD series; it sits underneath the application pipelines and the Centralized Azure Pipeline YAML Templates + Azure Artifacts Feeds library, and it deploys into the landing zone described in Enterprise Azure DevOps Platform: Multi-Project Structure & VMSS Agents.

What problem this solves

Terraform makes it trivially easy to write infrastructure once. It makes it deceptively hard to write infrastructure once and reuse it correctly across forty teams for five years. The gap between those two is where platform teams live, and the symptoms are specific. A vulnerability disclosure says “every storage account must disable shared-key auth by Friday.” If your storage logic is a published module at v3.4.0, you change one repo, cut v3.5.0, and tell teams to bump the ref — a one-line PR each. If your storage logic is copy-pasted into thirty root configs, you are now grepping thirty repos, finding nine variants, and hoping you caught them all. The module-as-artifact model turns a fleet-wide change from an archaeology project into a version bump.

What breaks without this discipline: drift (copies that diverge silently), un-reviewable plans (a monorepo where every change re-plans the world), no rollback (a main-branch module source means a consumer’s terraform init can pick up a breaking change the author pushed an hour ago, with no way to pin back), and state blast radius (one state file for Prod and Non-Prod, so a fat-fingered destroy in Dev can reach Production resources). Each of these has cost a real team a real outage. The repo-per-module + semver-tag + Git-ref-pin pattern, with per-environment remote state, is the antidote — and it is exactly the pattern the public Terraform Registry uses, rebuilt inside an Azure DevOps org where you control the agents, the feeds, and the management groups.

Who hits this: any team past the first dozen Terraform resources. It bites hardest on platform teams who own shared modules consumed by application teams they don’t control, on regulated enterprises that must prove “which exact version of the network module deployed this subnet,” and on anyone whose terraform plan has started taking minutes and showing diffs nobody reads. The fix is not “write better HCL” — it is “treat the module like a library and the root config like an application that depends on it.”

To frame the whole field before the deep dive, here is the lifecycle of a single change as it moves through the module and the consumers that depend on it:

Stage Where it happens The artifact The control Most common mistake
Author tf-module-vnet repo, feature/* branch main.tf + variables.tf + outputs.tf PR review + module CI Adding a required variable with no default (breaking change, called a patch)
Validate Module CI on VMSS agent fmt / validate / tflint / checkov Pipeline gates Skipping terraform validate because “it’s just a variable rename”
Release main after merge A semver git tag (2.1.0) Tag-protection + conventional commits Re-tagging an existing version (mutable releases)
Consume Root config, module "vnet" { source = "git::…?ref=2.1.0" } The pinned source line PR review of the bump Pinning to main or a branch (non-reproducible)
Plan Non-Prod / Prod apply pipeline A saved plan file Plan-then-apply gate Applying without reviewing the saved plan
Apply Non-Prod (auto) / Prod (gated) The azurerm state delta Remote state lock + approval Sharing one state across environments

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with core Terraform: the init / plan / apply loop, what a provider and a resource are, that a module is a folder of .tf files you can call with module "name" { source = … }, and that Terraform records reality in a state file. If those are shaky, start with Terraform Fundamentals: HCL, Providers & State and the broader Infrastructure as Code: Core Concepts — State, Drift, Idempotency. You should know Git branching well enough to follow GitFlow (feature → development → release → main, plus hotfix/*) — see Git Branching Strategies Explained. And you should know the shape of an Azure DevOps pipeline — stages, jobs, steps, agents — from Azure Pipelines Fundamentals: Stages, Jobs, Templates & Triggers.

This sits in the Infrastructure as Code track, one rung above single-module authoring and one rung below platform-scale orchestration. The authoring mechanics (inputs, outputs, structure) are covered in depth by Terraform Modules: Authoring Structure, Inputs, Outputs & Versioning; the consumption and registry mechanics by Terraform Module Sources, Composition & Registry Consumption. This article is the Azure-DevOps-specific, enterprise-real synthesis: how those generic ideas become repos, tags, pipelines and state files in an org with shared agents and a CAF landing zone. It deploys into the management-group/Key-Vault/hub-spoke structure laid out in Enterprise Azure DevOps Platform: Multi-Project Structure & VMSS Agents, and it is consumed by the application pipelines in Centralized Azure Pipeline YAML Templates + Azure Artifacts Feeds.

A quick map of who owns what during a change, so you route a question to the right team:

Layer What lives here Who owns it What it can break
Module repo (tf-module-*) The reusable HCL + its CI + its tags Platform / IaC team Every consumer, if a breaking change ships as a patch
Root config (live deployment) module blocks pinned to refs, backend config App team (or platform, per stack) One environment’s state
Remote state (azurerm) The tfstate blobs + locks Platform team Concurrent applies, blast radius
VMSS agent fleet The runners executing terraform Platform team (hub) All pipelines, if agents starve or lose network
Landing zone (mgmt groups, KV, hub-spoke) The deploy target Cloud foundation team What the modules are allowed to create
Apply pipelines Plan/apply orchestration + gates Platform team Whether a bad plan reaches Prod

Core concepts

Six mental models make every later decision obvious.

A module is a versioned library, not a folder you copy. The single most important reframing in this whole article: a Terraform module is a published artifact with a stable interface (its input variables and output values) and an immutable version (a git tag). Consumers depend on a version, not on “whatever is in the repo today.” This is exactly how you treat a NuGet package or an npm library. The moment you copy a module instead of referencing a version of it, you have forked, and forks drift. The moment you reference main instead of v2.1.0, you have a non-reproducible build that can change under you without a commit on your side.

The interface is the contract; the body is an implementation detail. A module’s variables.tf (what it accepts) and outputs.tf (what it returns) are its public API. Everything in main.tf and locals.tf is private. Consumers should be able to use the module from the README.md and the examples/ folder alone, never reading main.tf. This is why naming, descriptions, defaults, validation and outputs matter so much: they are the documentation. Change the body freely; change the interface and you have changed the version’s semantics, which the version number must reflect.

Semantic versioning encodes compatibility, and the git tag is the release. A version MAJOR.MINOR.PATCH (e.g. 2.1.3) tells a consumer what kind of change this is without reading the diff. MAJOR = a breaking interface change (renamed/removed a variable, changed a default that alters behaviour, changed an output’s shape) — consumers must read release notes before bumping. MINOR = backward-compatible feature (a new optional variable, a new output). PATCH = a backward-compatible fix (a bug, a tag, a refactor with no interface change). The git tag is the immutable release; once 2.1.3 exists it must never be moved. Mutable tags are the cardinal sin — a consumer’s init cache and a fresh init would disagree on what 2.1.3 means.

Repo-per-module gives each module an independent lifecycle. One Git repo per module means each module has its own commit history, its own PR reviews, its own CI run, its own version line, and its own access control. The VNet module can ship v5.x while the Key Vault module sits at v1.x — they version independently because they change independently. A bug fix to the MySQL module triggers a tiny CI run on that one repo, not a re-validation of the entire estate. This is the same instinct as one-repo-per-microservice: independent deploys, independent blast radius.

The root config is the application; the module is its dependency. A root configuration (a “live” or “deployment” repo, e.g. tf-live-corp-nonprod) is the thing you actually terraform apply. It is small: a backend block, provider config, and a handful of module blocks that compose pinned module versions with environment-specific inputs. It owns no resource logic of its own — it wires modules together. The same root config exists per environment (Non-Prod and Prod), each pointing at its own backend, so the code differs only in input values while the state is physically separate.

State is the source of truth, and its partitioning is your blast radius. Terraform’s state maps your HCL to real Azure resources. With the azurerm backend, state is a blob in an Azure Storage account, and the backend provides native locking via blob lease, so two pipelines can’t apply the same state at once. How you split state is the single biggest reliability decision: one state per environment per stack means a plan is fast and small, a destroy can only reach one environment’s one stack, and two teams never block each other. One giant state means slow plans, huge blast radius, and constant lock contention.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this is the mental model side by side:

Concept One-line definition Where it lives Why it matters here
Module A reusable folder of .tf with an input/output interface Its own tf-module-* repo The unit you version and reuse
Root config The live deployment that composes modules A tf-live-* repo The thing you apply
Interface A module’s variables.tf + outputs.tf In the module repo The public contract consumers pin to
Git tag (semver) An immutable named release MAJOR.MINOR.PATCH On the module repo’s main The version a consumer references
Source + ref git::https://…/tf-module-x?ref=2.1.0 In a module block Makes consumption reproducible & auditable
azurerm backend State stored as a blob in Azure Storage Storage account + container Where reality is recorded; provides locking
State partition One tfstate per env per stack Blob key, e.g. nonprod/network.tfstate Defines blast radius and plan speed
Module CI fmt/validate/tflint/checkov/docs + tagger Pipeline in the module repo Quality gate + release automation
Apply pipeline Plan → (gate) → apply for a root config Pipeline in the live repo How change reaches Azure safely
VMSS agents Shared self-hosted runners in the hub Azure VM Scale Set Execute every terraform command
.tpl template A templatefile() source (cloud-init, policy JSON) templates/*.tpl in the module Renders dynamic text from variables
examples/ Runnable sample root configs for the module examples/ in the module repo Doc + the thing tests/ exercises

Module anatomy: every file and what it’s for

Open any well-run tf-module-* repo in the IaC project and you see the same skeleton. This consistency is itself a feature — a stranger knows where to look before they read a line. Here is the canonical layout for the virtual-network module, which mirrors the production Terraform Module: Azure Virtual Network:

tf-module-vnet/
├── main.tf            # the resources — the implementation body
├── variables.tf       # input interface: every var, type, default, validation
├── outputs.tf         # output interface: what consumers can read back
├── locals.tf          # computed/derived values, naming, tag merges
├── versions.tf        # required_version + required_providers (NO backend here)
├── README.md          # generated header + usage; terraform-docs target
├── .tflint.hcl        # lint ruleset for this module
├── templates/
│   ├── nsg-rules.json.tpl   # rendered by templatefile() in main.tf
│   └── ddos-plan.json.tpl
├── examples/
│   ├── simple/        # minimal root config calling the module
│   │   ├── main.tf
│   │   └── outputs.tf
│   └── complete/      # all features exercised
│       └── main.tf
└── tests/
    ├── vnet.tftest.hcl      # native terraform test
    └── README.md

Each file earns its place. The table below is the reference — what it holds, why it’s separate, and the mistake people make:

File Holds Why a separate file Common mistake
main.tf The resource and nested module blocks — the body The implementation; the only file that creates things Cramming variables and locals inline instead of splitting
variables.tf Every variable with type, description, default, validation It’s the public input contract; reviewers read it first Missing description (breaks terraform-docs) or no type
outputs.tf Every output with description (and sensitive where needed) The public return contract; consumers wire these onward Not exporting the IDs a consumer will obviously need
locals.tf Derived values, naming conventions, merged tags Keeps main.tf readable; one place for naming logic Putting business logic that should be a variable into a local
versions.tf terraform { required_version } + required_providers Pins the toolchain contract; lives apart from the backend Putting a backend block in a module (modules never declare backends)
README.md Usage, inputs/outputs table, examples Human + machine doc; the terraform-docs injection target Hand-maintaining the inputs table (let terraform-docs own it)
templates/*.tpl Text rendered by templatefile() (JSON, cloud-init, scripts) Separates large literal text from HCL Inlining a 60-line jsonencode instead of a .tpl
examples/* Runnable sample root configs Doubles as documentation and the test target Examples that don’t actually init/plan (rot silently)
tests/*.tftest.hcl Native terraform test cases Proves the module plans/applies as claimed No tests, so a refactor silently breaks an output

main.tf — the body

main.tf holds the resource blocks and any nested module calls. It is the only file that creates infrastructure, and it should read like prose: resources named for what they are, values pulled from local.* and var.*, no magic literals. A fragment of the VNet module’s main.tf:

resource "azurerm_virtual_network" "this" {
  name                = local.vnet_name
  location            = var.location
  resource_group_name = var.resource_group_name
  address_space       = var.address_space
  dns_servers         = var.dns_servers
  tags                = local.tags
}

resource "azurerm_subnet" "this" {
  for_each = var.subnets

  name                 = each.value.name
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = each.value.address_prefixes

  dynamic "delegation" {
    for_each = each.value.delegation == null ? [] : [each.value.delegation]
    content {
      name = delegation.value.name
      service_delegation {
        name    = delegation.value.service_name
        actions = delegation.value.actions
      }
    }
  }
}

Conventions that pay off at scale: name the primary resource this (so module.vnet.azurerm_virtual_network.this is predictable across every module), drive collections with for_each over a map (stable addresses, no index churn — unlike count), and gate optional nested blocks with dynamic so a null input cleanly omits them. The count-vs-for_each choice is load-bearing:

Meta-argument Addresses look like Add/remove one item Use when
count …this[0], …this[1] Re-indexes everything after it (churn/destroy) A simple on/off toggle (count = var.enabled ? 1 : 0)
for_each (map) …this["app"], …this["data"] Touches only that key — stable Any collection of distinct, named things (subnets, rules)
for_each (set) …this["10.0.1.0/24"] Stable by value A set of unique scalars

variables.tf — the input contract

This file is your module’s API surface. Every variable carries a type (never untyped), a description (terraform-docs renders it; a reviewer reads it), a default only when the value is genuinely optional, and a validation block where a bad value should fail fast at plan rather than produce a confusing Azure API error. A representative slice:

variable "address_space" {
  description = "CIDR blocks for the VNet, e.g. [\"10.10.0.0/16\"]."
  type        = list(string)

  validation {
    condition     = length(var.address_space) > 0
    error_message = "address_space must contain at least one CIDR block."
  }
}

variable "subnets" {
  description = "Map of subnets keyed by logical name."
  type = map(object({
    name             = string
    address_prefixes = list(string)
    delegation = optional(object({
      name         = string
      service_name = string
      actions      = list(string)
    }))
  }))
  default = {}
}

variable "tags" {
  description = "Tags merged over the module's mandatory tags."
  type        = map(string)
  default     = {}
}

The decisions that separate a good interface from a fragile one:

Decision Good practice Why Anti-pattern
Required vs optional Required = no default; optional = sensible default Adding a required var later is a breaking (major) change Defaulting a value that has no safe default (e.g. a name)
Typing Always set type; use object() for structured input Catches misuse at plan; documents shape type = any (hides errors, defeats validation)
optional() attributes Use for optional object fields with defaults Lets the object grow without breaking callers Two near-identical variables instead of one optional field
validation Guard ranges/enums/formats with a clear message Fails at plan with your message, not an opaque 400 No validation → cryptic Azure API errors at apply
sensitive Mark secret inputs sensitive = true Keeps values out of plan output / logs Leaking a secret into pipeline logs
nullable nullable = false when null is never valid Prevents null slipping through for_each/locals Silent nulls breaking downstream expressions
Descriptions One sentence, include an example terraform-docs surfaces it; PR reviewers rely on it Empty description → useless generated README

outputs.tf — the return contract

Outputs are how a consuming root config wires one module into the next — the VNet’s subnet_ids feeds the App Service module’s subnet_id, the Key Vault’s vault_uri feeds an app setting. Export every ID and attribute a consumer will plausibly need; under-exporting forces them to fork or to use a brittle data source. Mark secrets sensitive.

output "vnet_id" {
  description = "Resource ID of the virtual network."
  value       = azurerm_virtual_network.this.id
}

output "subnet_ids" {
  description = "Map of subnet logical name => subnet resource ID."
  value       = { for k, s in azurerm_subnet.this : k => s.id }
}

output "vnet_name" {
  description = "Name of the virtual network."
  value       = azurerm_virtual_network.this.name
}
Output rule Why
Export resource IDs for every primary and child resource Consumers compose modules by ID; missing IDs force forks
Return maps keyed by logical name for for_each collections Lets a consumer pick module.vnet.subnet_ids["data"] by name
Mark generated secrets sensitive = true Keeps them out of plan output and pipeline logs
Add a description to every output terraform-docs renders the outputs table from these
Never output something you don’t want to be a contract Every output is a promise; removing one is a breaking change

locals.tf — derived values and naming

Locals are computed values: a naming convention assembled from inputs, a merge of mandatory tags over caller tags, a flattened structure. Keeping them here keeps main.tf clean and gives the module one place where naming lives.

locals {
  vnet_name = coalesce(
    var.name,
    "vnet-${var.workload}-${var.environment}-${var.location_short}"
  )

  mandatory_tags = {
    managed_by  = "terraform"
    module      = "tf-module-vnet"
    environment = var.environment
  }

  tags = merge(local.mandatory_tags, var.tags)
}

A rule worth internalising: if a value should be controllable by the consumer, it is a variable; if it is derived from variables, it is a local. The naming pattern above is a local because it is derived — but it still respects a var.name override via coalesce, so the module is opinionated by default and flexible when needed.

versions.tf — the toolchain contract

This file pins the tools, not the infrastructure. It declares the minimum Terraform version and the provider source + version constraints. It is deliberately separate from any backend (modules never declare backends — only root configs do).

terraform {
  required_version = ">= 1.7.0, < 2.0.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 3.110, < 4.0"
    }
  }
}
Constraint style Meaning When to use Risk
>= 3.110, < 4.0 Any 3.x at or above 3.110 Modules — allow patches, block the next major A minor could still introduce a surprise
~> 3.110 >= 3.110, < 3.111 (pessimistic, patch only) Tightest; very conservative modules Frequent constraint bumps needed
= 3.110.0 Exactly this Almost never in a module Forces every consumer to your exact pin
(no constraint) Anything Never A provider major bump silently breaks you

Modules should set permissive-but-bounded provider constraints (allow patch/minor, block the next major) and let the root config’s lock file (.terraform.lock.hcl) pin the exact provider version. That division — module declares a range, root config locks the point — is what lets one module work across many consumers without forcing a single provider version on all of them.

.tpl templates — dynamic text from variables

A .tpl file is the source for Terraform’s templatefile() function: it renders a text file (JSON, a cloud-init script, an NSG rule set, a policy document) by substituting ${...} interpolations from a variables map. Use it whenever you’d otherwise embed a large literal blob in HCL. The VNet module renders a DDoS/NSG JSON from a .tpl:

resource "azurerm_network_security_group" "this" {
  name                = "nsg-${var.workload}-${var.environment}"
  location            = var.location
  resource_group_name = var.resource_group_name
}

locals {
  nsg_rules = jsondecode(templatefile("${path.module}/templates/nsg-rules.json.tpl", {
    allowed_cidrs = var.allowed_cidrs
    app_port      = var.app_port
  }))
}
[
%{ for idx, cidr in allowed_cidrs ~}
  {
    "name": "allow-${idx}",
    "priority": ${100 + idx},
    "direction": "Inbound",
    "access": "Allow",
    "protocol": "Tcp",
    "source_address_prefix": "${cidr}",
    "destination_port_range": "${app_port}"
  }%{ if idx < length(allowed_cidrs) - 1 },%{ endif }
%{ endfor ~}
]
.tpl use case Example Why a template beats inline HCL
Cloud-init / custom data for the VMSS agents agent-init.sh.tpl Multi-line shell with interpolated vars stays readable
Policy / NSG / RBAC JSON nsg-rules.json.tpl Loops (%{ for }) generate N rules cleanly
App config files baked into an image appsettings.json.tpl One source of truth, rendered per environment
ARM/Bicep passthrough for an unsupported resource deploy.json.tpl Bridges a gap until the provider supports it

examples/ and tests/ — proof the module works

The examples/ folder holds runnable root configs that call the module — a simple/ minimal case and a complete/ case exercising every feature. They are documentation a reader can copy and the target your tests exercise. The tests/ folder holds native terraform test files (*.tftest.hcl), which init/plan (and optionally apply against ephemeral resources) the examples and assert on outputs:

# tests/vnet.tftest.hcl
run "plan_simple" {
  command = plan
  module {
    source = "./examples/simple"
  }
  assert {
    condition     = length(output.subnet_ids) == 1
    error_message = "simple example must create exactly one subnet"
  }
}
Folder Contains Run by Catches
examples/simple Minimal invocation, defaults only Docs + terraform test “Does the happy path even plan?”
examples/complete Every variable/feature set Docs + terraform test “Does the full feature surface plan together?”
tests/*.tftest.hcl run blocks with assert on outputs terraform test in CI A refactor that silently changes an output/shape
tests/ (Terratest, optional) Go tests that apply + check Azure A nightly/heavier pipeline Real-apply regressions (see Terraform Testing: Native & Terratest)

The repo-per-module strategy

Now the structural decision the whole article hinges on: one Git repo per module, all living under the IaC project of the Azure DevOps org. tf-module-vnet, tf-module-key-vault, tf-module-mysql-flexible, tf-module-app-service, tf-module-private-endpoint, tf-module-management-group — each is its own repo, its own pipeline, its own version line. This is not the only way to organise modules; it is the way that scales to forty teams. Here is the comparison every team has, explicitly:

Layout One repo per module (this) Modules monorepo modules/ inside each app repo
Versioning Independent semver tags per module — clean Shared tag forces lockstep, or per-path tag hacks None — “version” is “whatever’s in the repo”
Blast radius of a change One module, one CI run Whole repo re-validated; noisy Copied → drift across N repos
Reuse across teams git::…/tf-module-x?ref=… — first-class Path-into-monorepo refs — workable but clumsy Copy-paste — the original sin
Access control Per-repo permissions All-or-nothing on the monorepo Mixed with app code
CI cost Tiny, targeted runs Large runs on any change (unless path filters) Re-runs app CI for infra edits
Discoverability Repo list = module catalogue One repo, many folders to learn Hidden inside apps
Overhead Many repos to template/govern One repo to clone Lowest setup, highest long-term cost
Best for A platform team serving many consumers A small team, few modules, one cadence A prototype that will be refactored out

The repo-per-module choice costs you repo proliferation — dozens of repos to create, template, and govern. You pay that down with a module-repo template (a seed repo with the skeleton, the CI pipeline YAML, the .tflint.hcl, branch policies and a CODEOWNERS file) that az repos can clone for each new module. The benefit — independent versioning, tiny CI, first-class reuse, per-repo access control — is decisive once more than a handful of teams consume your modules.

Branching inside a module repo

Each module repo follows GitFlow: feature/* → development → release/* → main, with hotfix/* for urgent fixes straight off main. The version tag is cut when a release/* merges to main. Branch policies on development and main require the module CI to pass and a PR review. The branching detail is in Enterprise Branching Strategy & Multi-Stage CI/CD; the module-specific rules are:

Branch Purpose Policy Triggers
feature/* One change in progress CI runs on PR to development Module CI (validate-only)
development Integration of merged features Protected; PR + green CI required Module CI (full)
release/x.y.z Stabilise a version for release Protected; only fixes Module CI + version-bump prep
main Released code; tags live here Protected; tag-protection on Tag-on-merge → semver tag
hotfix/* Urgent fix off main Fast-track PR Module CI + patch tag

Naming and the module catalogue

Consistent repo names are your catalogue. The convention tf-module-<provider>-<service> (or tf-module-<service> when the org is Azure-only) makes the repo list browsable and the source URLs predictable. A small index repo or a wiki page maps module → repo → latest tag → owner, but the naming alone gets you 80% of discoverability:

Repo name Module Latest tag Mirrors production module
tf-module-vnet Hub/spoke virtual network + subnets 5.2.0 Azure Virtual Network
tf-module-key-vault Key Vault + access/RBAC + PE 3.4.1 Azure Key Vault
tf-module-mysql-flexible MySQL Flexible Server + DBs 2.1.0 Azure MySQL Flexible Server
tf-module-private-endpoint Private Endpoint + DNS A-record 4.0.2 Azure Private Endpoint
tf-module-mgmt-group CAF management-group hierarchy 1.3.0 Azure Management Group
tf-module-vmss The shared agent scale set 2.0.0 Azure VM Scale Set

Versioning & semantic git-tag releases

A module release is a git tag, and the tag is immutable. That is the entire release mechanism — no package upload, no registry publish step (though Azure DevOps does offer a private Terraform registry; more below). When a release/2.1.0 branch merges to main, the CI cuts an annotated tag 2.1.0 on that commit, and from that instant 2.1.0 means exactly that commit forever. Consumers reference ?ref=2.1.0. Re-pointing the tag would mean two consumers who both “use 2.1.0” get different code — the defect that destroys trust in a module estate.

Deciding the bump is the discipline. Read the change against the interface, not the diff size:

Change Bump Why Example
Add an optional variable (has a default) MINOR Existing callers unaffected; new capability Add enable_ddos = false
Add a new output MINOR Pure addition to the contract Export nsg_id
Remove or rename a variable MAJOR Existing callers break Rename cidraddress_space
Add a required variable (no default) MAJOR Every caller must now supply it Add required environment
Change a default that alters behaviour MAJOR Silent behaviour change for existing callers Flip public_network_access default to false
Change an output’s type/shape MAJOR Downstream wiring breaks subnet_ids list → map
Fix a bug, no interface change PATCH Behaviour corrected, contract intact Correct a wrong priority calc
Internal refactor, same plan PATCH Invisible to callers Split a resource into for_each with moved
Bump provider minimum in versions.tf MINOR (usually) New floor; callers above it unaffected >= 3.100>= 3.110
Raise provider major ceiling MAJOR Could surface provider breaking changes < 4.0< 5.0

The az CLI to cut a tag (the CI does this automatically, but here it is by hand) and to list what exists:

# Cut an annotated, immutable release tag on main
git checkout main && git pull
git tag -a 2.1.0 -m "feat: add optional ddos protection plan"
git push origin 2.1.0

# What versions exist? (sorted)
git tag --list --sort=version:refname

# A consumer can resolve a ref via the Azure DevOps API too:
az repos ref list --repository tf-module-vnet \
  --filter tags --query "[].name" -o tsv

Pre-release and the deprecation path

For a breaking change you want a consumer to trial first, cut a pre-release tag (3.0.0-rc.1) — a consumer can opt in with ?ref=3.0.0-rc.1 while everyone else stays on 2.x. And when you must rename a variable, ship a deprecation bridge rather than a hard break where possible: accept both names for a minor, warn, then remove the old in the next major.

Technique How When
Pre-release tag git tag 3.0.0-rc.1 Let early adopters validate a major before GA
Deprecation variable Accept cidr and address_space; coalesce; warn Soften a rename across one minor cycle
moved {} block Re-address a resource without destroy/create Refactor countfor_each as a PATCH, no churn
Changelog / release notes Markdown in the tag/PR enumerating breaking changes Every MAJOR — consumers read before bumping

A moved block deserves a callout because it lets you ship a refactor that would otherwise force a destroy/recreate as a non-breaking patch:

# Refactor count → for_each without destroying existing subnets
moved {
  from = azurerm_subnet.this[0]
  to   = azurerm_subnet.this["app"]
}

Azure DevOps as a private Terraform registry (optional)

Azure DevOps Artifacts supports a private Terraform module registry (the registry-style module "x" { source = "<org>/<feed>/.../azurerm" } form), which gives you registry semantics — version constraints like ~> 2.1 — instead of exact git refs. It is a valid alternative to git-ref sourcing; the trade-off:

Approach Git source + ref Azure DevOps private registry
Pin style Exact tag (?ref=2.1.0) Version constraint (version = "~> 2.1")
Auditability The ref is the version, in the code Resolved at init; lock file records it
Setup Zero — just a repo and a tag Publish step into an Artifacts feed
Floating to latest patch Manual ref bump ~> 2.1 auto-takes new patches
Best for Maximum explicitness, regulated change Teams wanting registry-style constraints

This article uses git source + ref as the default because the ref in the code is the most auditable, reproducible form — “which version deployed this?” is answered by reading the root config, not by resolving a constraint. Choose the registry if your teams prefer constraint-based pinning.

Consuming modules in root configs

A root configuration composes pinned modules into a deployment. It is small and declarative: a backend, providers, and module blocks. The source line is where reproducibility lives — every module block references a module repo by Git source and an immutable ref:

# tf-live-corp-nonprod/network/main.tf

module "vnet" {
  source = "git::https://dev.azure.com/acme/IaC/_git/tf-module-vnet?ref=5.2.0"

  workload            = "corp"
  environment         = "nonprod"
  location            = var.location
  location_short      = "cin"
  resource_group_name = azurerm_resource_group.network.name
  address_space       = ["10.20.0.0/16"]

  subnets = {
    app  = { name = "snet-app",  address_prefixes = ["10.20.1.0/24"] }
    data = { name = "snet-data", address_prefixes = ["10.20.2.0/24"] }
  }

  tags = local.common_tags
}

module "key_vault" {
  source = "git::https://dev.azure.com/acme/IaC/_git/tf-module-key-vault?ref=3.4.1"

  workload            = "corp"
  environment         = "nonprod"
  resource_group_name = azurerm_resource_group.network.name
  location            = var.location
  subnet_id           = module.vnet.subnet_ids["data"]   # output → input wiring
  tags                = local.common_tags
}

The anatomy of that source URL, and the variants you’ll meet:

Source form Example Notes
Azure DevOps HTTPS git git::https://dev.azure.com/<org>/<project>/_git/<repo>?ref=<tag> The org standard; the apply runs as a service connection / PAT-less
Azure DevOps SSH git git::git@ssh.dev.azure.com:v3/<org>/<project>/<repo>?ref=<tag> When agents auth via SSH key
Sub-directory in a repo git::https://…/_git/tf-modules//vnet?ref=<tag> The // selects a folder (monorepo style)
Pin to a tag (do this) ?ref=5.2.0 Immutable, reproducible, auditable
Pin to a branch (don’t) ?ref=main Floating — changes under you with no commit
Pin to a commit SHA ?ref=a1b2c3d Maximally precise but unreadable; tags are better

Why ref-to-a-tag, not ref-to-a-branch

This is the most common, most damaging consumption mistake, so it gets its own table. ?ref=main means your terraform init pulls whatever is on the module’s main at the moment you init — which the module author may change an hour later. Two applies of “the same” root config can produce different plans. A tag is frozen.

Behaviour ?ref=5.2.0 (tag) ?ref=main (branch)
Reproducible across time Yes — frozen forever No — changes when author pushes
Reproducible across agents Yes No — depends on init timing
Upgrade is an explicit PR Yes — bump the ref, review the plan No — happens silently, no diff in your repo
Rollback Pin to the previous tag Impossible — there’s no “previous”
Audit “what deployed this?” Read the ref Must reconstruct git history by date
Acceptable in production Yes Never

Composition: wiring modules together

Composition is the act of feeding one module’s outputs into another’s inputs — the root config is a graph, and Terraform resolves the order from the references. The pattern above wires module.vnet.subnet_ids["data"] into the Key Vault’s subnet_id. Keep root configs flat (call modules directly, avoid deep module-nesting more than ~2 levels) and let outputs do the wiring. The composition mechanics are covered deeply in Terraform Module Sources, Composition & Registry Consumption and the design principles in Terraform Module Design, Composition & Versioning.

Composition pattern Shape When
Flat composition Root calls modules A, B, C; wires outputs→inputs The default — readable, debuggable
Shallow nesting A “stamp” module groups a few leaf modules A repeated bundle (e.g. “spoke” = vnet+nsg+rt)
Deep nesting (avoid) Modules calling modules calling modules Hides the graph; plans become opaque
Data-source wiring Read an existing resource via data instead of output Cross-state references (the other stack owns it)

Remote state & per-environment isolation

State is where Terraform records reality, and with the azurerm backend it is a blob in an Azure Storage account. The backend gives you two things for free that you must otherwise build: durable shared state (the team and the pipelines see the same state) and native locking (a blob lease prevents two applies from racing on the same state). The backend block lives in the root config, never in a module:

# tf-live-corp-nonprod/network/backend.tf
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate-nonprod"
    storage_account_name = "sttfstatenonprod001"
    container_name       = "tfstate"
    key                  = "corp/nonprod/network.tfstate"
    use_azuread_auth     = true              # auth as the pipeline identity, no key
  }
}

The single most important design decision is the key — the blob path — because it defines how state is partitioned, which defines your blast radius and plan speed. The rule: one state file per environment per stack. A “stack” is a cohesive group of resources that change together (network, data, app). Non-Prod and Prod use separate storage accounts (not just separate keys) so a Prod state can have its own firewall, RBAC and lock — Non-Prod credentials can never touch Prod state.

Partition strategy Key pattern Plan size / speed Blast radius Lock contention
One state, everything all.tfstate Huge / minutes Catastrophic (one apply touches all) Constant — everyone blocks
Per environment only nonprod.tfstate, prod.tfstate Large Whole environment High within an env
Per env + per stack (do this) corp/nonprod/network.tfstate Small / seconds One stack in one env Low — stacks apply in parallel
Per env + per stack + per region corp/nonprod/eus/network.tfstate Smallest One stack, one env, one region Lowest

The storage-account topology for isolation:

Scope Storage account Container Who can write Lock mechanism
Non-Prod state sttfstatenonprod001 tfstate Non-Prod pipeline identity (RBAC) Blob lease (automatic)
Prod state sttfstateprod001 (separate account) tfstate Prod pipeline identity only Blob lease (automatic)
Backend bootstrap Created once, out-of-band Platform team N/A (chicken-and-egg)

Two operational notes that bite teams. First, the state storage account is a chicken-and-egg: you cannot store the backend’s own state in the backend it creates. Bootstrap it once with a tiny config using local state (or click-ops), then never touch it from the main estate. Second, use_azuread_auth = true means the apply authenticates as the pipeline’s identity (a service connection / workload identity) with RBAC on the storage account, so there is no access key in the pipeline — exactly the secrets discipline from Pipeline Secrets Management. The deep mechanics of partitioning, locking and migration are in Terraform Remote State at Scale and Terraform Backends Deep Dive.

Cross-stack references

When the app stack needs the network stack’s outputs, do not merge them into one state. Read the other stack’s state via terraform_remote_state, or (better for loose coupling) re-resolve the resource with a data source:

data "terraform_remote_state" "network" {
  backend = "azurerm"
  config = {
    resource_group_name  = "rg-tfstate-nonprod"
    storage_account_name = "sttfstatenonprod001"
    container_name       = "tfstate"
    key                  = "corp/nonprod/network.tfstate"
  }
}

# use: data.terraform_remote_state.network.outputs.subnet_ids["app"]
Cross-stack method Coupling Trade-off
terraform_remote_state Reads the other stack’s outputs Couples to its state layout; fast
data source (re-resolve) Reads Azure directly by name/ID Loose; resilient to state changes; needs the name/ID
Pass via variable (CI wires it) Pipeline reads output, injects as -var Explicit; the pipeline owns the wiring

The module CI pipeline

Every tf-module-* repo carries an azure-pipelines.yml that runs the module CI on the shared VMSS agents. Its job is to gate quality on every PR and to cut the release tag on merge to main. The stages: format check, validate, lint, security scan, docs check, and (only on main) tag. Here is the real pipeline:

# tf-module-vnet/azure-pipelines.yml
trigger:
  branches: { include: [ development, main ] }
pr:
  branches: { include: [ development ] }

pool:
  name: 'hub-vmss-linux'        # the shared self-hosted VMSS agent pool

variables:
  TF_VERSION: '1.7.5'

stages:
- stage: validate
  displayName: 'Validate & scan module'
  jobs:
  - job: checks
    steps:
    - task: TerraformInstaller@1
      inputs: { terraformVersion: $(TF_VERSION) }

    - script: terraform fmt -check -recursive
      displayName: 'fmt -check'

    - script: terraform init -backend=false
      displayName: 'init (no backend — modules have none)'

    - script: terraform validate
      displayName: 'validate'

    - script: |
        curl -sSL https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
        tflint --init && tflint -f compact --recursive
      displayName: 'tflint'

    - script: |
        pip install checkov
        checkov -d . --quiet --compact --framework terraform
      displayName: 'checkov (security/compliance)'

    - script: |
        docker run --rm -v "$(System.DefaultWorkingDirectory):/data" \
          quay.io/terraform-docs/terraform-docs:0.18.0 \
          markdown table --output-check --output-file README.md /data
      displayName: 'terraform-docs (README drift check)'

    - script: terraform test
      displayName: 'terraform test (examples)'

- stage: release
  displayName: 'Tag release'
  dependsOn: validate
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - job: tag
    steps:
    - checkout: self
      persistCredentials: true
    - script: |
        VERSION=$(cat VERSION)          # e.g. "5.2.0", bumped in the release PR
        git tag -a "$VERSION" -m "release $VERSION"
        git push origin "$VERSION"
      displayName: 'Cut immutable semver tag'

What each gate does and why it’s non-negotiable:

Stage Tool Catches Fails the PR when
fmt terraform fmt -check Inconsistent formatting Any file isn’t canonically formatted
init (no backend) terraform init -backend=false Broken module references / providers Init can’t resolve providers/sub-modules
validate terraform validate HCL/type/reference errors The config is syntactically/semantically invalid
tflint tflint + azurerm ruleset Provider anti-patterns, deprecated args, bad names A lint rule trips (e.g. invalid VM size)
checkov checkov Insecure/non-compliant config (open NSG, no encryption) A policy check fails (see Checkov/tfsec scanning)
terraform-docs --output-check README drift (inputs/outputs table stale) The generated table differs from committed README
terraform test terraform test A change that breaks an example/output Any assert fails
tag (main only) git tag — (it’s the release) (only runs on main, after all gates pass)

tflint and checkov, concretely

tflint enforces Terraform/provider correctness — deprecated arguments, invalid enum values (an impossible VM SKU), unused declarations, naming conventions — via the azurerm ruleset in .tflint.hcl:

# .tflint.hcl
plugin "azurerm" {
  enabled = true
  version = "0.27.0"
  source  = "github.com/terraform-linters/tflint-ruleset-azurerm"
}
rule "terraform_naming_convention" { enabled = true }
rule "terraform_unused_declarations" { enabled = true }

checkov enforces security/compliance policy — it fails the build if the module would create, say, a storage account without HTTPS-only, an NSG open to 0.0.0.0/0 on 22, or a Key Vault without soft-delete. You can suppress a specific check inline with a justification comment when a finding is a knowing exception:

# checkov:skip=CKV_AZURE_35:Service endpoints are intentionally used here, not PE
Scanner Layer Example finding Suppression
tflint Terraform/provider correctness “vm_size ‘Standard_FOO’ is invalid” Disable the rule in .tflint.hcl
checkov Security & compliance policy “CKV_AZURE_X: Key Vault soft-delete not enabled” Inline checkov:skip=ID:reason
terraform validate Syntax & types “Reference to undeclared variable” Fix the code (never suppress)

Non-Prod vs Prod apply pipelines

Each root config has two pipelines, and the asymmetry between them is the safety model. The Non-Prod pipeline plans and auto-applies to Dev and SIT — fast feedback, low risk, no human in the loop for these throwaway-ish environments. The Prod pipeline plans, publishes the plan as an artifact, and applies only behind a gated Azure DevOps Environment approval — a human (a lead + a production manager) reviews the exact plan that will run, then approves. Critically, the apply consumes the saved plan file, so what gets reviewed is exactly what gets applied (no TOCTOU gap between plan and apply).

# tf-live-corp-nonprod/azure-pipelines.yml  (Non-Prod — auto-apply)
trigger:
  branches: { include: [ development ] }
pool: { name: 'hub-vmss-linux' }

stages:
- stage: dev
  jobs:
  - deployment: apply_dev
    environment: 'corp-nonprod-dev'      # no approval gate on Dev
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self
          - task: TerraformInstaller@1
            inputs: { terraformVersion: '1.7.5' }
          - script: terraform init
            displayName: 'init (azurerm backend, AAD auth)'
          - script: terraform plan -out=tfplan
            displayName: 'plan'
          - script: terraform apply -auto-approve tfplan
            displayName: 'apply (auto)'
# tf-live-corp-prod/azure-pipelines.yml  (Prod — gated)
trigger: none                            # Prod is never auto-triggered on push
pool: { name: 'hub-vmss-linux' }

stages:
- stage: plan
  jobs:
  - job: plan
    steps:
    - checkout: self
    - task: TerraformInstaller@1
      inputs: { terraformVersion: '1.7.5' }
    - script: terraform init
    - script: terraform plan -out=tfplan -input=false
      displayName: 'plan'
    - publish: tfplan                    # the EXACT plan to be applied
      artifact: prod-plan

- stage: apply
  dependsOn: plan
  jobs:
  - deployment: apply_prod
    environment: 'corp-prod'             # this Environment has approval + checks
    strategy:
      runOnce:
        deploy:
          steps:
          - download: current
            artifact: prod-plan
          - task: TerraformInstaller@1
            inputs: { terraformVersion: '1.7.5' }
          - script: terraform init
          - script: terraform apply -input=false "$(Pipeline.Workspace)/prod-plan/tfplan"
            displayName: 'apply the reviewed plan'

The two pipelines side by side:

Aspect Non-Prod pipeline Prod pipeline
Trigger Auto on push to development Manual / promoted only (trigger: none)
Targets Dev, SIT Pre-Prod, Production
Plan published as artifact Optional Required — it’s what’s reviewed
Approval gate None (Dev) / light (SIT) Gated — lead + production manager
Apply input Fresh plan or -auto-approve The saved plan file (no re-plan)
Environment object corp-nonprod-dev corp-prod (approvals + branch-control checks)
Rollback story Re-run with previous ref Re-apply previous tag’s plan; gated

The gate lives on the Azure DevOps Environment, not in YAML — you configure Approvals and checks on the corp-prod environment in the portal (or via API). The CLI to create the environment-bound pipelines and inspect runs:

# Create the Prod apply pipeline from its YAML
az pipelines create --name "tf-live-corp-prod" \
  --repository tf-live-corp-prod --branch main \
  --yml-path azure-pipelines.yml --skip-first-run true

# Manually queue a Prod plan/apply (it never auto-triggers)
az pipelines run --name "tf-live-corp-prod" --branch main

# See whether a run is waiting on the environment approval
az pipelines runs show --id <runId> --query "{state:state,result:result}"

Plan-then-apply discipline

The reason Prod saves and re-applies the plan file rather than re-planning at apply time is correctness: between a plan and a later apply, reality can change (someone click-ops a resource, another pipeline applies). Re-planning at apply would silently widen the diff past what was approved. Applying the saved binary plan guarantees the approved change is the applied change — and if reality drifted enough that the saved plan is now stale, the apply fails closed rather than doing something unreviewed.

Property Plan-then-save-then-apply (this) Plan-and-apply-together
Reviewer sees the real change Yes — the saved plan No — apply re-plans
Approved == applied Guaranteed Not guaranteed (drift window)
Stale plan behaviour Fails closed Silently applies a wider diff
Best for Production, regulated change Dev throwaway environments

Architecture at a glance

Read the diagram left to right; it traces a single change from a developer’s keystroke to a resource in the Production landing zone. On the far left, a platform engineer opens a PR on a module repo — say tf-module-vnet — in the IaC project of the Azure DevOps organisation. That PR triggers the module CI on the shared VMSS agent fleet sitting in the hub network: fmt, validate, tflint, checkov, terraform-docs and terraform test. When the release/* branch merges to main, the CI cuts an immutable semver tag (5.2.0) — that tag is the release. Nothing about consumers changes yet; a new version simply exists.

The change reaches infrastructure only when a root config (a tf-live-* repo) bumps its module block’s ?ref= to 5.2.0 in a PR. That PR’s merge triggers the apply pipelines, which also run on the VMSS agents: the Non-Prod pipeline auto-plans and applys into Dev/SIT against the Non-Prod state blob; the Prod pipeline plans, publishes the plan, and waits on a gated approval before applying the saved plan into Production against the physically-separate Prod state. Every apply reads and writes azurerm remote state in Azure Storage (one blob per environment per stack, blob-lease locked), authenticates as the pipeline identity, and creates resources inside the CAF landing zone — management groups, a Key Vault per scope, and the hub-spoke network. The numbered badges mark the five control points where this pipeline lives or dies: the module anatomy (one repo per module, no copy-paste), the immutable semver tag (never moved, major for a breaking interface), the source + ref pin (a tag, never ?ref=main), the gated Prod apply (a saved plan behind lead + production-manager approval), and state isolation (one blob per env per stack, locked). Each is a place where a careless choice silently re-introduces drift, blast radius, or an unreviewed change.

Left-to-right enterprise Terraform delivery architecture: a platform engineer raises a PR on a tf-module repo in the Azure DevOps IaC project; the shared VMSS agent fleet runs module CI (terraform fmt, validate, tflint, checkov, terraform-docs, terraform test) and on merge to main cuts an immutable semantic-version git tag as the module release; a tf-live root configuration consumes the module via a git source pinned to that ref and is applied by two pipelines — a Non-Prod pipeline that auto-plans and applies to Dev and SIT and a gated Prod pipeline that plans, publishes the plan and applies the saved plan only after lead and production-manager approval — each writing to an azurerm remote-state blob in Azure Storage that is isolated per environment and stack with blob-lease locking, provisioning resources into a CAF landing zone of management groups, a Key Vault per scope and a hub-spoke virtual network; numbered badges mark the five control points where the model lives or dies: module anatomy and one-repo-per-module, the immutable semantic-version tag, the source-plus-ref pin to a tag rather than main, the gated production apply of a saved plan behind lead and production-manager approval, and per-environment-per-stack state isolation with blob-lease locking

The whole method reduces to: a module is a versioned artifact (left), the tag is the release (centre-left), the root config pins a version (centre), two pipelines turn a version bump into a reviewed apply (centre-right), and isolated state bounds the blast radius (right). Localise any problem to a zone, and you know which control failed.

Real-world scenario

Meridian Logistics runs its freight-tracking platform on Azure under one Azure DevOps organisation: an IaC project with eighteen tf-module-* repos, a templates project, a packages project, and twenty-two application projects, all served by a single VMSS agent pool in the hub. The platform team is five engineers. Their Terraform estate had grown organically and was now actively dangerous in two ways.

First, the network module was sourced as ?ref=main in fourteen root configs — a convenience that let teams “always get the latest.” Second, Non-Prod and Production shared one state file per environment family (network.tfstate for everything non-prod, but Prod’s network and Prod’s data stacks shared prod.tfstate), so a Prod data change re-planned the Prod network too, and a single apply held a lock the whole team queued behind.

The incident landed on a Tuesday. A platform engineer pushed a fix to tf-module-vnet main that flipped a subnet’s private_endpoint_network_policies default from Disabled to Enabled — a sensible hardening, shipped as a quiet commit, not a tagged release because the repo had never adopted tags. Within the hour, three unrelated application pipelines ran their nightly apply. Because they sourced ?ref=main, each picked up the changed module with no version bump and no PR on their side. Two of them planned a change to a subnet that an existing Private Endpoint depended on; one applied it before anyone noticed, and a payment-service Private Endpoint went unhealthy. The on-call engineer saw a failed health check and a Terraform apply in the audit log they couldn’t explain — no root config had changed. That was the maddening part: the diff lived in a module repo nobody’s pipeline “deployed.”

The breakthrough was asking the right question: what version of the network module did this apply use? The answer — “main, as of whenever it inited” — was the whole bug. They could not reproduce the plan, could not roll back to “the previous version” (there wasn’t one), and could not prove which commit each pipeline had pulled. The lock contention compounded it: rolling forward a fix meant queuing behind every other Prod apply on the shared prod.tfstate.

The fix was the pattern in this article, applied in three moves. That night: pin every consumer to the current good commit via an explicit tag — they cut tf-module-vnet 4.7.0 on the last-known-good commit and changed all fourteen ?ref=main to ?ref=4.7.0 in one sweep of PRs, instantly making every build reproducible and freezing the bad commit out. The following week: adopt the module CI + tag-on-merge pipeline on all eighteen module repos (so a behaviour change like the PE-policy flip would have had to be a 5.0.0 major with release notes, and consumers would have opted in deliberately). And: re-partition state to one blob per environment per stack (corp/prod/network.tfstate, corp/prod/data.tfstate, …) in separate Non-Prod and Prod storage accounts, collapsing both the blast radius and the lock contention.

The next month a similar hardening shipped — disabling shared-key auth on a storage module — as tf-module-storage 6.0.0 with notes. Teams bumped the ref in their own PRs, saw the exact plan, and rolled it out environment by environment with zero surprise applies. The lesson on the platform team’s wall: “A module without a version is a time bomb with a slow fuse. The ref in the code is the only honest answer to ‘what did we deploy?’”

The incident as a timeline, because the order is the lesson:

Time Event Why it hurt What should have been true
T+0 Engineer pushes a behaviour change to module main Shipped as a commit, not a tagged release It should have required a 5.0.0 major tag
T+1h Three nightly pipelines apply They sourced ?ref=main — silently pulled it They should pin ?ref=4.x.y
T+1h05 A Private Endpoint goes unhealthy A module change deployed with no consumer PR A version bump is an explicit, reviewed PR
T+1h20 On-call sees an apply they can’t explain “No root config changed” — main drifted under them The ref in the code would name the version
T+2h Can’t roll back No previous version existed An immutable previous tag enables instant rollback
Night Cut 4.7.0, sweep all refs off main First time the estate was reproducible This is the baseline, not the fix
+1 wk Module CI + tag-on-merge on all 18 repos Behaviour changes now force a deliberate major The permanent fix
+1 wk Re-partition state per env+stack Killed blast radius and lock contention Should have been the original design

Advantages and disadvantages

The repo-per-module + semver-tag + isolated-state model is the right default for a platform team serving many consumers, but it is not free. Weigh it honestly:

Advantages (why this model wins) Disadvantages (what it costs)
Reproducible builds — ?ref=2.1.0 means the same code forever, on every agent Repo proliferation — dozens of tf-module-* repos to create, template and govern
Fleet-wide fixes are a version bump, not an archaeology dig across copies Upgrade fatigue — consumers must actively bump refs; lagging teams stay on old versions
Small, fast plans and tiny blast radius from per-env-per-stack state More state files to bootstrap, secure (RBAC) and back up
Independent module lifecycles — each versions at its own cadence Cross-stack wiring is now explicit work (remote_state/data), not implicit
The ref in the code is an audit trail — “what deployed this?” answers itself Semver discipline is human — a mislabeled major-as-patch still breaks consumers
Module CI catches insecure/invalid config before it ever reaches a consumer CI on every module repo is more pipeline surface to maintain
Gated Prod apply of a saved plan = approved-equals-applied Two pipelines per root config and an approval workflow add process overhead
First-class reuse and per-repo access control A new engineer must learn the catalogue and the ref-bump workflow

The model is right when you have more than a handful of modules and more than one team consuming them, when you must prove which version deployed what, and when a bad Prod apply is expensive. It is overkill for a solo project or a five-resource estate — there, an in-repo modules/ folder and one state file are fine, and you graduate to this when the copy-paste pain or the plan-blast-radius pain actually appears. The disadvantages are all manageable with templating (a seed module repo), a registry or index for discoverability, and a Renovate-style ref-bump automation — but only if you know they exist, which is the point.

Hands-on lab

Build a real module repo, cut a tag, consume it from a root config against azurerm remote state, and run the module CI locally — all free-tier-friendly. You need an Azure subscription, the az CLI, and Terraform ≥ 1.7. Run in Cloud Shell (Bash) or locally.

Step 1 — Bootstrap the remote-state backend (the chicken-and-egg, done once).

RG=rg-tfstate-lab
LOC=centralindia
SA=sttfstatelab$RANDOM           # must be globally unique, lowercase
az group create -n $RG -l $LOC -o table
az storage account create -n $SA -g $RG -l $LOC --sku Standard_LRS \
  --min-tls-version TLS1_2 --allow-blob-public-access false -o table
az storage container create -n tfstate --account-name $SA \
  --auth-mode login -o table
echo "Backend SA: $SA"

Step 2 — Create the module repo skeleton.

mkdir -p tf-module-rg/{examples/simple,tests} && cd tf-module-rg

versions.tf:

terraform {
  required_version = ">= 1.7.0, < 2.0.0"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = ">= 3.110, < 4.0" }
  }
}

variables.tf:

variable "name"     { description = "Resource group name.";        type = string }
variable "location" { description = "Azure region.";               type = string }
variable "tags"     { description = "Tags to apply."; type = map(string); default = {} }

main.tf:

resource "azurerm_resource_group" "this" {
  name     = var.name
  location = var.location
  tags     = merge({ managed_by = "terraform", module = "tf-module-rg" }, var.tags)
}

outputs.tf:

output "id"       { description = "Resource group ID.";   value = azurerm_resource_group.this.id }
output "name"     { description = "Resource group name."; value = azurerm_resource_group.this.name }
output "location" { description = "Region.";              value = azurerm_resource_group.this.location }

Step 3 — Run the module CI gates locally (what the pipeline runs).

terraform fmt -check -recursive          # formatting gate
terraform init -backend=false            # modules have no backend
terraform validate                       # syntax/types
# Optional if installed:
# tflint --init && tflint -f compact
# checkov -d . --compact --framework terraform

Expected: fmt is silent (already formatted), validate prints Success! The configuration is valid.

Step 4 — Commit and cut a tag (the release).

git init -q && git add -A && git commit -qm "feat: initial resource group module"
git tag -a 1.0.0 -m "release 1.0.0"
# (In Azure DevOps you'd `git push origin 1.0.0` to a tf-module-rg repo;
#  locally we'll consume it by relative path to keep the lab self-contained.)

Step 5 — Create a root config that consumes the module against remote state.

cd .. && mkdir tf-live-lab && cd tf-live-lab

backend.tf (fill in your $SA from Step 1):

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate-lab"
    storage_account_name = "REPLACE_WITH_SA"
    container_name       = "tfstate"
    key                  = "lab/nonprod/rg.tfstate"
    use_azuread_auth     = true
  }
}

main.tf:

provider "azurerm" { features {} }

module "rg" {
  source   = "../tf-module-rg"          # in real life: git::https://…?ref=1.0.0
  name     = "rg-app-nonprod-lab"
  location = "centralindia"
  tags     = { environment = "nonprod", owner = "lab" }
}

output "rg_id" { value = module.rg.id }

Step 6 — Init, plan, apply.

az login   # if not already; remote state uses your AAD identity
terraform init                          # configures the azurerm backend
terraform plan -out=tfplan              # review: 1 resource group to add
terraform apply tfplan                  # applies the SAVED plan
terraform output rg_id                  # the wired output

Expected: init reports the backend initialised; plan shows Plan: 1 to add; apply creates rg-app-nonprod-lab; the state blob lab/nonprod/rg.tfstate now exists in your storage account (verify in the portal or az storage blob list).

Step 7 — Prove the version pin (simulate an upgrade).

# Change the module, cut 1.1.0, and bump the ref to see an explicit, reviewable upgrade
cd ../tf-module-rg
# (add an optional variable, e.g. `variable "lock" { ... default = false }`)
git add -A && git commit -qm "feat: optional management lock" && git tag -a 1.1.0 -m "release 1.1.0"
# In the root config you would change ?ref=1.0.0 -> ?ref=1.1.0, then plan to SEE the change.

Step 8 — Teardown.

cd ../tf-live-lab
terraform destroy -auto-approve         # removes the resource group
cd .. && az group delete -n rg-tfstate-lab --yes --no-wait   # removes the state SA

You have now built a versioned module, released it as an immutable tag, consumed it against isolated remote state, and run the exact gates the module CI runs. Swap the relative source for a git::…?ref= and this is the production pattern.

Common mistakes & troubleshooting

The failure modes below are the ones that actually page a platform team. Each is symptom → root cause → how to confirm (exact command) → fix.

# Symptom Root cause Confirm Fix
1 An apply you can’t explain — no root config changed A consumer sources ?ref=main; the module author pushed to main grep -r 'ref=main' . in the live repos; check module main history vs apply time Pin every consumer to a tag (?ref=5.2.0); ban main/branch refs in PR policy
2 “Two consumers on 2.1.0 behave differently” The 2.1.0 tag was moved (mutable release) git for-each-ref refs/tags/2.1.0; compare commit on two clones Never re-tag; cut a new version; enable tag-protection on main
3 Error: Backend initialization required on every run Backend config changed, or first init missing terraform init output; check backend.tf matches the SA/container/key terraform init -reconfigure (or -migrate-state if intentional)
4 Error acquiring the state lock — apply hangs/aborts Another apply holds the blob lease, or a prior run died holding it az storage blob show -c tfstate -n <key> --query properties.lease Wait for the other run; if stale, terraform force-unlock <ID> (carefully)
5 A tiny change re-plans hundreds of resources One state for the whole environment (no per-stack split) Look at the plan’s resource count; inspect the backend key Re-partition state per env+stack; terraform state mv or re-import into split states
6 terraform-docs gate fails the PR The README inputs/outputs table is stale Run terraform-docs markdown table --output-check . locally Regenerate: terraform-docs markdown table --output-file README.md . and commit
7 checkov fails the build on a known exception A policy check flags an intentional design Read the CKV_AZURE_* ID in the build log Add # checkov:skip=CKV_AZURE_X:reason inline, with a real justification
8 Consumer’s init can’t fetch the module Agent lacks auth to the module repo, or a typo’d source URL terraform init error; test git ls-remote <source-url> on the agent Grant the pipeline identity read on the IaC repos; fix the git::https://… URL
9 Plan and apply disagree (apply does more than reviewed) Prod re-plans at apply instead of applying the saved plan Check the apply step — is it apply tfplan or a bare apply? Always plan -out=tfplan, publish it, and apply tfplan in the gated stage
10 Adding a variable “broke everyone” A required variable (no default) shipped as a minor/patch Read variables.tf of the new tag; is there a default? Give it a default (minor) or release it as a major with notes
11 for_each change destroys and recreates resources Switched countfor_each (or changed keys) without moved terraform plan shows destroy+create of unchanged resources Add moved {} blocks to re-address; release as a PATCH
12 Provider version drift between consumers Module over-pins (= 3.110.0) or under-pins (no constraint) terraform version; inspect versions.tf and each .terraform.lock.hcl Set a range in the module (>= 3.110, < 4.0); let the root lock file pin
13 terraform test fails after a refactor A refactor silently changed an output’s name/shape Read the failing assert; diff outputs.tf Restore the output (or bump major if intended) and update the test
14 Prod apply ran without approval The gate is in YAML, or the Environment has no checks Inspect the corp-prod Environment → Approvals and checks Move the gate to the Environment (Approvals + branch control), not YAML
15 Secret leaked into pipeline/plan logs A secret input/output not marked sensitive Search the run log; check variable/output for sensitive Mark sensitive = true; rotate the leaked secret; use Key Vault references

Three reading notes that save the most time. First, when an apply surprises you, the first question is always “what version of every module did this run use?” — if the answer involves the word “main,” you’ve found it (#1). Second, a state lock that won’t clear is usually a legitimately running apply, not a stuck one — force-unlock is a last resort that can corrupt state if you race a live apply (#4). Third, “it broke everyone” is almost always a semver lie — a breaking change shipped as a non-major — and the fix is both the immediate revert and the process change so the next breaking change is a deliberate major (#10).

Best practices

Security notes

Security in a module estate is about who can change what and what the modules are allowed to create. The module is upstream of every consumer, so a compromised or careless module is a fleet-wide risk — which is exactly why the CI gates and the access model matter.

Cost & sizing

Terraform itself is free (open source); the cost is the Azure resources the modules create plus the pipeline minutes the agents burn. The module/versioning machinery is nearly free — the spend is elsewhere, and the discipline in this article actively reduces it by keeping plans small and preventing the “scale up to mask a bug” reflex.

Cost driver What drives it Rough figure How to control
State storage A few blobs per env+stack (KBs–MBs each) < ₹100 / month (Standard_LRS) Negligible; just don’t store huge state
VMSS agent compute Agent VMs running plans/applies Per the scale set’s VM size × hours Autoscale to 0 when idle; right-size the VM SKU
Pipeline minutes CI runs per module PR + applies per consumer Self-hosted = your VM cost, not per-minute fees Path filters; small targeted module CI runs
The deployed Azure resources What the modules actually create The real bill (VNets, KV, MySQL, …) This is where 99% of cost is — see each module’s article
Drift/re-plan waste Huge plans re-evaluating the world Indirect (agent time, engineer time) Per-env-per-stack state = small fast plans

Sizing guidance: the VMSS agent pool is sized for concurrency of plans/applies, not raw compute — a handful of Standard_D2s_v5-class agents that autoscale to zero when idle serve a large org cheaply, because terraform plan is mostly API I/O, not CPU. Keep the agents in the hub so they reach Private-Endpoint-only PaaS without egress over the internet (cheaper and safer). The single biggest cost-of-discipline win is per-env-per-stack state: a team whose plans dropped from 9 minutes to 20 seconds reclaimed both agent-minutes and engineer-attention, and stopped the “scale up to make the slow apply finish” anti-pattern. For the cost of each deployed module, see its dedicated article (e.g. Azure MySQL Flexible Server); this layer’s own overhead rounds to the agent VMs plus a few hundred KB of blobs.

Interview & exam questions

1. Why source a module by ?ref=<tag> instead of ?ref=main? A tag is an immutable release — ?ref=2.1.0 resolves to the same commit forever, on every agent, so builds are reproducible and “what version deployed this?” is answered by reading the code. ?ref=main floats: a terraform init pulls whatever is on main at that instant, so the module author can change your build with no commit on your side, and you cannot roll back to a “previous version” because none exists.

2. A change adds a new required variable to a module. What semver bump, and why? Major. A required variable (no default) means every existing consumer’s apply now fails until they supply it — a breaking interface change. Adding an optional variable (with a default) would be a minor, because existing callers are unaffected. The bump encodes compatibility so consumers know whether they can bump blindly (minor/patch) or must read release notes (major).

3. Explain the repo-per-module strategy and one concrete benefit over a monorepo. Each module lives in its own Git repo with its own commit history, CI, version tags and access control. The concrete benefit: independent versioning and blast radius — the VNet module can ship v5.x while Key Vault sits at v1.x, a fix to one module triggers a tiny CI run on only that repo, and a consumer pins each module’s version separately. A monorepo forces shared tags (lockstep versioning) or per-path tag hacks and re-validates the whole repo on any change.

4. Where does the backend block live, and where does it not? In the root configuration, never in a module. Modules are reusable and backend-agnostic; the root config that actually runs apply declares the azurerm backend (storage account, container, and the key that partitions state). Putting a backend in a module would make it un-reusable across environments.

5. How do you partition azurerm state, and why does it matter? One state file (blob key) per environment per stack — e.g. corp/nonprod/network.tfstate, with Non-Prod and Prod in separate storage accounts. It matters because the partition defines blast radius (a destroy reaches only one stack in one environment), plan speed (small state = fast plan), and lock contention (stacks apply in parallel instead of queuing on one lock). One giant state means slow plans, catastrophic blast radius and constant lock waits.

6. What does the module CI enforce, and what’s the consequence of a failed gate? terraform fmt -check, init -backend=false, validate, tflint, checkov, terraform-docs --output-check, and terraform test. A failed gate fails the PR, so the change can’t merge to main; since the release tag is cut only on merge to main, a failed gate means no version can be created. Quality is gated before a version ever exists for a consumer to pin.

7. Why does the Prod apply consume a saved plan file instead of re-planning? To guarantee approved-equals-applied. Between plan and a later apply, reality can drift (a click-ops change, another pipeline). Re-planning at apply would silently widen the diff beyond what the lead/production-manager approved. Applying the saved binary plan means exactly the reviewed change runs; if reality drifted enough to invalidate the plan, the apply fails closed rather than doing something unreviewed.

8. A consumer reports “two of us on 2.1.0 get different behaviour.” Diagnose. The 2.1.0 tag was moved — someone re-tagged a different commit, so two clones (or an init cache vs a fresh init) disagree on what 2.1.0 means. Confirm by comparing the commit each tag points to (git for-each-ref refs/tags/2.1.0 on each clone). Fix: never re-tag; cut a new version; enable tag-protection so main tags are immutable.

9. How should a module constrain its provider version, and where is the exact version pinned? The module declares a range in versions.tf (e.g. >= 3.110, < 4.0) — permissive enough to serve many consumers, bounded to block the next provider major. The exact provider version is pinned by the root config’s .terraform.lock.hcl (with checksums). Module declares the range, root config locks the point — that division lets one module work across consumers without forcing one provider version on all.

10. You switched a resource from count to for_each and the plan wants to destroy and recreate it. Fix without downtime? Add moved {} blocks to re-address each instance from its old count index to its new for_each key (e.g. from = …this[0] to = …this["app"]). Terraform then updates state addresses in place with no destroy/create, so it ships as a non-breaking patch. Without moved, the index-to-key change reads as “delete old, create new.”

11. What’s use_azuread_auth = true on the azurerm backend, and why use it? It makes Terraform authenticate to the state storage account as the caller’s Azure AD identity (the pipeline’s workload-identity service connection) with RBAC, instead of using a storage access key. Benefit: no key in the pipeline, RBAC scopes who can write each environment’s state, and it fits the PAT-less/least-privilege secrets model — the Non-Prod identity has no path to the Prod state account.

12. Where does the Prod approval gate live, and why not in YAML? On the Azure DevOps Environment (corp-prod) as Approvals and checks, not in the pipeline YAML. Putting it on the Environment means it’s enforced for every pipeline that deploys to that environment, can’t be bypassed by editing a YAML file, and is managed by the people who own the environment (not whoever edits the pipeline). A deployment job targeting the environment pauses until the approvers (lead + production manager) sign off.

These map to the HashiCorp Certified: Terraform Associate (modules, sources, versioning, state, backends) and to Azure DevOps practice on AZ-400 (DevOps Engineer Expert)design and implement a strategy for IaC, manage pipelines, environments and approvals. A compact mapping for revision:

Question theme Primary cert Objective area
Module sources, ?ref, versioning Terraform Associate Use & create modules; module sources
State partitioning, azurerm backend, locking Terraform Associate Implement and maintain state
Provider constraints vs lock file Terraform Associate Providers; the dependency lock file
moved, refactors, deprecation Terraform Associate Read, generate & modify configuration
Module CI, gates, scanning AZ-400 Implement a secure IaC pipeline strategy
Environments, approvals, gated apply AZ-400 Manage infrastructure & config; release gates

Quick check

  1. A root config sources a module as ?ref=main and a nightly pipeline applyed a change nobody made in that repo. What’s the root cause and the one-line fix?
  2. You add a brand-new variable with a sensible default. Major, minor, or patch — and what would make it a major instead?
  3. True or false: the backend "azurerm" block belongs inside the reusable module so every consumer shares state.
  4. Your terraform plan for a one-tag change re-evaluates 300 resources and takes minutes. What design decision caused this, and what fixes it?
  5. Why does the Prod pipeline publish the plan and then apply that saved file, instead of running plan and apply together?

Answers

  1. The consumer is pinned to a branch, so terraform init pulled whatever the module author had pushed to main — a version bump with no PR on the consumer’s side. Fix: pin to an immutable tag (?ref=5.2.0) and ban branch refs in PR policy.
  2. Minor — a new optional variable (with a default) doesn’t affect existing callers. It would be a major only if the variable were required (no default), since every existing consumer’s apply would then fail until they supplied it.
  3. False. The backend belongs in the root configuration, never in a module — modules are reusable and backend-agnostic, and each root config points at its own per-environment, per-stack state blob.
  4. One state file for the whole environment (no per-stack split), so every plan re-evaluates everything. Fix: partition state per environment per stack (corp/nonprod/network.tfstate, …) so each plan is small and fast and the blast radius is one stack.
  5. To guarantee approved-equals-applied. Reality can drift between plan and apply; re-planning at apply would silently widen the diff past what was reviewed. Applying the saved binary plan runs exactly the approved change, and fails closed if the plan has gone stale.

Glossary

Next steps

You can now build and version a fleet of Terraform modules on Azure DevOps and deploy them safely into a landing zone. Build outward:

TerraformAzure DevOpsInfrastructure as CodeModule VersioningazurermRemote StatePlatform EngineeringGitFlow
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading