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:
- Lay out a Terraform module repo to the canonical anatomy —
main.tf,variables.tf,outputs.tf,locals.tf,.tpltemplates,examples/,tests/,README.md,versions.tf— and explain what each file is for. - Justify the one-repo-per-module strategy over a monorepo or in-repo
modules/folder, and name the precise trade-offs each way. - Cut a semantic-version git tag as an immutable module release, decide major/minor/patch correctly, and never re-tag.
- Write the
git::https://…?ref=<tag>source line that consumes a module reproducibly, and explain whyrefto a tag beatsrefto a branch or a floatingmain. - Design azurerm remote state partitioning so each environment and stack has its own state file, with native state locking, and Non-Prod physically isolated from Prod.
- Build the module CI pipeline on Azure DevOps —
terraform fmt -check,validate,tflint,checkov,terraform-docs, and an automated tag-on-merge — running on the shared VMSS agents. - Build the dual apply pipelines — a Non-Prod pipeline that plans and auto-applies to Dev/SIT, and a Prod pipeline that plans, publishes the plan, and applies only behind a gated environment approval.
- Run a disciplined module upgrade: bump the ref in a root config, read the plan, ride a deprecation, and roll back by pinning to the previous tag.
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 cidr → address_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 count→for_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.
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 count→for_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
- One repo per module; one module per repo. Each gets its own version line, CI, and access control. Seed new ones from a template repo so the skeleton, CI YAML and branch policies are identical everywhere.
- Pin consumers to immutable tags, never to
mainor a branch.?ref=5.2.0is reproducible and auditable;?ref=mainis a silent time bomb. Enforce it with a PR policy that rejects branch refs. - Treat the version number as the contract. Breaking interface change = major, new optional feature = minor, fix = patch. When in doubt, bump higher — an unnecessary major costs a PR; a missed major costs an outage.
- Never move a tag. Releases are immutable. Enable tag-protection on
main. If you shipped a bad2.1.0, cut2.1.1, don’t re-point2.1.0. - The module declares a provider range; the root config locks the point.
>= 3.110, < 4.0inversions.tf, exact pin in the consumer’s.terraform.lock.hcl. This lets one module serve many consumers. - Partition state per environment per stack, in separate Non-Prod and Prod storage accounts. Small plans, small blast radius, no cross-environment reach, low lock contention.
- Authenticate to state and Azure as the pipeline identity (
use_azuread_auth), never a stored key. No secrets in the pipeline; RBAC on the storage account scopes who can write each state. - Gate Prod on the Environment, and apply the saved plan. Approved-equals-applied — the lead and production manager review the exact binary plan that runs; a stale plan fails closed.
- Make the module CI mandatory: fmt, validate, tflint, checkov, terraform-docs, terraform test. No green CI, no merge; no merge, no tag. Quality is gated before a version can ever exist.
- Let terraform-docs own the README inputs/outputs table. Hand-maintained tables rot; an
--output-checkgate keeps docs honest. - Ship a deprecation bridge for renames; use
moved {}for refactors. Don’t make consumers’ lives hard — soften breaks across a minor and re-address resources instead of destroying them. - Keep root configs flat and thin. They wire pinned modules together with environment inputs; they own no resource logic. If a root config is growing logic, that logic wants to be a module.
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.
- Least privilege on the module repos. The IaC project’s repos are write-restricted to the platform team; consumers get read. A consumer should never be able to push to a module repo — they consume versions, they don’t author them. Branch policies (PR + green CI) on
development/mainmean even the platform team can’t merge unreviewed. - The pipeline identity, not a human, holds the cloud credentials. Applies run as a workload-identity service connection (PAT-less) with RBAC scoped to exactly the management-group scope it deploys into — the Non-Prod identity cannot touch Prod, and neither can touch the other corp’s scope. This is the secrets discipline from Pipeline Secrets Management: nothing sensitive lives in YAML or variables.
- State is sensitive — protect it like a secret. A
tfstateblob contains resource attributes and sometimes secrets. The state storage accounts are private (no public blob access, TLS 1.2+), RBAC-scoped to the relevant pipeline identity, and — for Prod — in a separate account with its own firewall. Enable soft-delete and versioning on the container so a corrupted state can be recovered. - checkov is your policy gate, not a suggestion. It fails the build on insecure module config — an NSG open to the world, a Key Vault without soft-delete/purge-protection, a storage account with shared-key auth or public access. A
checkov:skipmust carry a written justification and is itself reviewable in the PR. Pair it with Checkov/tfsec IaC scanning in the pipeline. - Mark every secret
sensitive. Any variable or output carrying a secret getssensitive = trueso it stays out of plan output and pipeline logs. Better still, modules accept Key Vault references or resolve secrets at apply via adata.azurerm_key_vault_secret, so the secret never sits in HCL or state in cleartext — wiring into the Key-Vault-per-scope of the landing zone. - Pin the toolchain, not just the infra.
required_versionand provider constraints prevent a surprise Terraform/provider upgrade from changing behaviour. The agents run a known Terraform version; the lock file pins providers with checksums, so a tampered provider download fails the hash check.
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
- A root config sources a module as
?ref=mainand a nightly pipelineapplyed a change nobody made in that repo. What’s the root cause and the one-line fix? - You add a brand-new variable with a sensible
default. Major, minor, or patch — and what would make it a major instead? - True or false: the
backend "azurerm"block belongs inside the reusable module so every consumer shares state. - Your
terraform planfor a one-tag change re-evaluates 300 resources and takes minutes. What design decision caused this, and what fixes it? - Why does the Prod pipeline
publishthe plan and thenapplythat saved file, instead of runningplanandapplytogether?
Answers
- The consumer is pinned to a branch, so
terraform initpulled whatever the module author had pushed tomain— 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. - 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.
- 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.
- 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. - 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
- Module — a reusable folder of
.tffiles with an input/output interface, lived in its owntf-module-*repo and consumed by version. - Root configuration (live deployment) — the small, environment-specific config you actually
terraform apply; it composes pinned modules and declares the backend. Owns no resource logic. - Interface — a module’s public API: its
variables.tf(inputs) andoutputs.tf(outputs). Changing it changes the version’s semantics. - Semantic version (semver) —
MAJOR.MINOR.PATCH; major = breaking interface change, minor = backward-compatible feature, patch = backward-compatible fix. - Git tag (release) — an immutable named commit (
2.1.0) that is a module release; consumers reference it via?ref=. Never moved. ?ref=<tag>— the query parameter on agit::module source that pins consumption to a specific immutable version.- azurerm backend — Terraform state stored as a blob in an Azure Storage account; provides durable shared state and native locking via blob lease.
- State partition — one
tfstateblob per environment per stack (the backendkey); defines blast radius, plan speed, and lock contention. - State lock — a blob lease the azurerm backend takes during apply so two runs can’t mutate the same state concurrently; cleared automatically,
force-unlockonly as a last resort. locals.tf— derived/computed values (naming, merged tags); distinct from variables, which are consumer-controllable inputs.versions.tf— the toolchain contract:required_versionandrequired_providersconstraints. Never contains a backend..tpltemplate — atemplatefile()source rendering text (JSON, cloud-init) from a variables map; for large literal blobs that don’t belong inline in HCL.examples/— runnable sample root configs that document the module and serve as the target fortests/.tests//terraform test— native*.tftest.hclcases thatplan/applyexamples andasserton outputs to catch regressions.- tflint — a linter for Terraform/provider correctness (deprecated args, invalid enums, naming); configured via
.tflint.hcl. - checkov — a security/compliance scanner that fails the build on insecure config; suppress a finding inline with
checkov:skip=ID:reason. - terraform-docs — generates the README inputs/outputs table;
--output-checkgates README drift in CI. moved {}block — re-addresses a resource in state without destroy/create, letting a refactor ship as a non-breaking patch.- Module CI — the per-module-repo pipeline (fmt, validate, tflint, checkov, terraform-docs, test, tag-on-merge) that gates quality and cuts the release.
- VMSS agents — the shared self-hosted Azure Pipelines agents on a VM Scale Set in the hub network that run every
terraformcommand. use_azuread_auth— backend setting to authenticate to state as the pipeline’s Azure AD identity (RBAC) instead of a storage access key.
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:
- Next: Centralized Azure Pipeline YAML Templates + Azure Artifacts Feeds — the reusable build templates and trusted dependency feeds that the application pipelines layer on top of this IaC foundation.
- Related: Enterprise Azure DevOps Platform: Multi-Project Structure & VMSS Agents — the org/project/agent structure and the CAF landing zone these modules deploy into.
- Related: Terraform Module Sources, Composition & Registry Consumption — go deeper on source addressing, the private registry, and composition patterns.
- Related: Terraform Remote State at Scale — the full treatment of state partitioning, locking, and cross-stack references.
- Related: Checkov, Trivy & tfsec: IaC Scanning in the Pipeline — harden the checkov gate in your module CI.
- Related: Enterprise Branching Strategy & Multi-Stage CI/CD on Azure DevOps — the GitFlow + multi-stage promotion model the apply pipelines follow.