Table of Contents
Over the last few weeks I’ve worked through the pieces of a Terraform platform one at a time. Repo structure gave modules and golden paths somewhere to live. Module boundaries decided what belongs in a module. Calling conventions set out how modules get sourced, pinned and wired together. And testing as governance turned those conventions into something a pipeline enforces rather than something a wiki suggests.
Each of those posts looked at one stage. This one is about the Terraform module lifecycle as a whole, and specifically the joins between the stages, because that’s where I’ve seen modules break. Not in the code. In the handoffs. A module gets designed by one person, reviewed by someone who doesn’t know what changed, released with a version number nobody thought about, and consumed by a team who finds out it was breaking when their plan wants to replace a Key Vault.
So rather than recap each stage, I want to treat the lifecycle as a series of gates. For each one, the useful question isn’t “what do we do here?” It’s “what has to be true before this module is allowed to move on?”
Design: write the contract before the resources
The temptation with a new module is to open main.tf and start writing resources. I’d resist that. The first files I want in a new module repo are variables.tf, outputs.tf and a consumer fixture that calls the module the way a real team would.
That sounds backwards, but it’s the same argument from the calling conventions post. Inputs and outputs are the module’s API. If the API is the thing consumers depend on, it’s the thing that deserves the most thought, and it’s much cheaper to argue about a variable name before there are fifty consumers using it.
The fixture is the design document. If I can’t write a fixture that reads naturally, with sensible defaults and nothing the consumer shouldn’t need to know, the interface isn’t right yet. It’s also the first test, so the work isn’t wasted.
The README follows from the contract rather than being written alongside it. I use terraform-docs to generate the inputs and outputs tables. Generating documentation doesn’t stop drift on its own, so the pipeline also checks the generated docs are current. The human-written part of the README is the bit terraform-docs can’t produce: what the module is for, what it deliberately doesn’t do, and which golden paths use it.
Gate: someone other than the author can read the variables, outputs and fixture, and explain what the module does and what it won’t do.
Build and test: covered, but not optional
I covered this properly last week, so I’ll keep it short. Contract tests check the inputs and outputs behave as promised. Consumer fixtures prove the module works the way it’s actually called. Provider upgrade runs tell you whether the next AzureRM release is going to hurt.
The point for the lifecycle is that this gate is mechanical. Nobody should be making a judgement call about whether tests passed.
Gate: the pipeline is green, and the tests that made it green haven’t been weakened in the same PR without a justified, reviewed reason.
Review: the only question that matters
Last week I made the point that passing every existing test doesn’t prove a change is non-breaking. Tests check what you thought to check. Review is where a human has to answer the question the tests can’t: is this a breaking change?
That question decides the version number, and the version number is the promise you make to every consumer. So it’s worth being explicit about what counts. For a module, I treat any of these as breaking:
- A variable or output is removed or renamed.
- A variable’s type is tightened, or a validation rule rejects values that used to be accepted.
- A new variable is required with no default.
- A default changes in a way that alters infrastructure or behaviour consumers already depend on.
- A resource address changes without a
movedblock, leaving Terraform to plan a destroy and recreate.
The fourth one is the sneaky one. Changing a default SKU or a network setting looks like a harmless improvement in the module repo. In a consumer’s plan, it might be an in-place change to production that nobody asked for.
Take a storage account module where someone changes the default value of its public_network_access_enabled input from true to false. It’s a perfectly sensible security improvement, and Terraform shows it as an in-place update, with no replacement at all. But if a consumer’s workload still reaches that account over its public endpoint, you’ve just broken it. The Terraform plan can be technically correct and the change can still be breaking for the consumer.
I don’t trust reviewers to spot all of this from a diff, so I make the author declare it. The module repo’s pull request template asks one question up front: is this a patch, a minor or a major change, and if it’s major, what does a consumer need to do to upgrade? The reviewer’s job then becomes checking the declaration rather than working it out from scratch, which is a much more reliable conversation.
Gate: the change type is declared, the reviewer agrees with it, and a major change comes with upgrade notes.
Refactoring without unnecessary destruction
That last item on the list deserves its own section, because Terraform gives module authors a way to avoid unnecessary resource replacement, and a lot of teams don’t use it.
If you refactor a module and a resource’s address changes, Terraform will normally plan to destroy the old address and create the new one. For a subnet or a Key Vault, that’s not a plan you want to discover by accident. A moved block inside the module tells Terraform the resource has simply been renamed:
moved {
from = azurerm_subnet.this[0]
to = azurerm_subnet.this["default"]
}
Because the block ships inside the module, consumers taking the new version can have that state address move handled for them. Their plan should show the move rather than a destroy and recreate caused purely by the address change. That can turn what would have been a breaking refactor into a compatible one, provided the resource configuration and the rest of the module contract haven’t changed in a breaking way.
I’d keep moved blocks indefinitely unless I could prove every consumer had already applied a version containing the move. A team jumping straight from v1.2.0 to v3.0.0 still needs the old address recognised. Removing that history can create exactly the replacement the block was meant to prevent. HashiCorp recommends retaining historical moves in published modules for the same reason.
There’s a related case when a module needs to stop managing something. A removed block, available from Terraform 1.7, lets you remove a resource from state without destroying the real infrastructure:
removed {
from = azurerm_monitor_diagnostic_setting.legacy
lifecycle {
destroy = false
}
}
But removed doesn’t transfer ownership. It only stops Terraform managing the resource. Something else needs to take responsibility for it, whether that’s another module, another state file or another team. Otherwise you’ve avoided a destructive plan by creating an unmanaged resource, which isn’t much of an improvement. And if the resource is moving between states, that handoff needs a deliberate import and a plan on both sides, not just a removed block.
The important distinction is that a clean state transition isn’t automatically a non-breaking release. Consumers still need to understand what they will own afterwards.
Release: the version number is a promise
The mechanics of tagging are in the provider pinning post: conventional commits on merge to main, and a workflow that cuts the tag so nobody has to remember to. Two things I’d add now that I’ve lived with that setup for a while.
The commit message that matters is the PR title. If your module repos use squash merges, and for a clean release history they probably should, the commit that lands on main can take its message from the pull request title rather than from the individual commits. So that’s what needs to follow the convention. Configure squash merges to use the PR title, and lint it in the pipeline:
name: PR Title
on:
pull_request_target:
types: [opened, reopened, edited, synchronize]
permissions:
pull-requests: read
jobs:
conventional-title:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pull_request_target needs care because it runs in a privileged context. This workflow only reads the PR title; it doesn’t check out or execute code from the pull request. In production I’d also pin the third-party action to its full commit SHA rather than a moving major-version tag.
This ties neatly back to the review gate. The author declares the change type in the PR template, the title carries it as fix:, feat: or feat!:, and the tagging workflow turns it into the version number. One decision, made once, by a human, and carried through automatically.
Major versions get a release candidate. For a patch or a minor, the tests and review are usually enough. For a major, I want a real consumer to try it before everyone else is offered it.
There’s a catch if every merge to main automatically publishes a stable release: a feat!: commit could create v2.0.0 before the pilot has tested anything. So the candidate stage has to be built into the release workflow, not bolted on afterwards. I’d use a controlled pre-release branch or an approval gate for major changes. The release automation produces something like v2.0.0-rc.1, a pilot consumer pins to that exact tag, runs a real plan against an existing environment and, where practical, applies it in non-production with a smoke test of the workload. A clean plan is useful evidence, but it isn’t proof that the application still works. Only after that succeeds do we promote the tested change to a stable v2.0.0.
Ideally the pilot is a friendly team that’s reasonably current. What they hit becomes the upgrade notes. The release is still automated; the difference is that the automation can’t skip the approval that makes the version trustworthy.
Every release gets notes, but the ones for a major version are the ones people actually read. “Breaking changes” is not upgrade guidance. “Rename sku_name to sku.name in your tfvars; if you’re only renaming the input, your plan should show no infrastructure changes” is.
And as I said in the calling conventions post, protect your tags with a repository ruleset. A version number is only a promise if nobody can quietly move it.
Gate: the tag exists, it’s protected, the release notes say what changed, and a major version has been through at least one real consumer before its stable release.
Consumption: a release nobody upgrades to isn’t finished
This is the stage most lifecycle diagrams stop before, and I think it’s the one that matters most. Cutting v1.4.0 doesn’t help anyone if every consumer is still on v1.1.0 and nobody knows.
Dependabot supports Terraform dependencies, including modules sourced from Git. For private Git module repos, it needs credentials to fetch the sources. One way to configure that is a git registry in the consumer’s dependabot.yml, with the token stored as a Dependabot secret:
version: 2
registries:
github-modules:
type: git
url: https://github.com
username: x-access-token
password: ${{ secrets.DEPENDABOT_MODULES_TOKEN }}
updates:
- package-ecosystem: "terraform"
directory: "/"
registries:
- github-modules
schedule:
interval: "weekly"
Now a new release can turn up as a pull request in each consumer repo. That PR should run the consumer’s normal validation and plan pipeline, assuming you’ve configured authentication and permissions for it. There’s a GitHub Actions detail worth knowing here: workflows triggered by Dependabot don’t get ordinary Actions secrets. They use Dependabot secrets, so a workflow that works for a developer PR may fail for an automated update unless you’ve designed the authentication path properly. Azure workload identity federation avoids storing an Azure client secret, but it still needs id-token: write permissions and a correctly configured trust relationship in Entra. For PR-triggered workflows, GitHub has traditionally issued a subject ending in :pull_request, but repositories created since July 2026 include immutable owner and repository IDs in that subject. The federated credential needs to match the format GitHub actually issues.
I’d also be careful about what that identity can access. A pull request can contain Terraform code nobody has reviewed yet. The identity used to produce a plan should have only the permissions it needs to do that, with deployment permissions kept separate. Removing a secret doesn’t remove the need to control who can use the identity.
Dependabot finds the update. The consumer pipeline proves whether it’s safe to take. Those are two separate responsibilities, and you need both. The team doesn’t have to go looking for upgrades, and it doesn’t have to take them blind either. It gets a PR with a plan to review before approval.
This is where the layered pinning from the calling conventions post pays off. For the referenced-module approach, Dependabot runs in the golden path repos to bump building blocks, and in consumer repos to bump the golden path. A fix to a helper module flows up one layer at a time, and each layer gets its own validation and review. Nobody gets a surprise.
If you’re using template repositories instead, the problem is different. Once a consumer repo is created, it’s a copy, not a reference. Dependabot can keep its declared module versions current, but it won’t propagate changes made to the golden path template itself. That needs a separate update or synchronisation strategy, which is one of the tradeoffs I covered in the repo structure post.
It’s also the reason I pin consumers to a full version rather than a moving major tag like v1. A moving ref can point at different code the next time Terraform downloads the module, without anyone changing the consumer repository. A full version, backed by a protected tag, means upgrades happen through a visible decision rather than an invisible change of what the ref points to.
There’s another useful signal here: how far behind the current version your consumers are. If a module has thirty consumers and twenty-five of them are three minor releases behind, you’ve got a maintenance problem even if every release pipeline is green. I’d want a simple view of versions declared in consumer repos, outstanding upgrade PRs and how long they’ve been open. Creating a safe upgrade path doesn’t mean much if nobody ever takes it.
Gate: consumers are offered the release as a PR, and an authenticated plan is reviewed before that upgrade is approved. Adoption is visible rather than assumed.
Deprecation: retiring things on purpose
Modules don’t just gain features, they shed them. The difference between a well-run platform and a painful one is whether consumers get warned before something disappears.
Terraform 1.15 added a native way to do this. Put a deprecated message on the variable, and any consumer who still passes a value gets a warning telling them what to use instead:
variable "sku_name" {
type = string
default = null
deprecated = "Use sku.name instead. sku_name will be removed in v3.0.0."
}
The same works on outputs. The catch is that older Terraform versions reject the argument outright, so if you adopt it, set required_version = ">= 1.15.0" in the module. A consumer on an older version then gets a clear version error rather than a module it can’t parse. If your consumers aren’t all there yet, a check block gets you most of the way. Failed check assertions are reported as warnings rather than errors, so the plan can still succeed while showing the consumer what’s changing:
variable "sku_name" {
type = string
default = null
description = "Deprecated. Use sku.name instead. Removed in v3.0.0."
}
check "deprecated_inputs" {
assert {
condition = var.sku_name == null
error_message = "sku_name is deprecated and will be removed in v3.0.0. Use sku.name instead."
}
}
Either way, the variable can keep working throughout v2.x, with the warning appearing in plans that still use it, and v3.0.0 removes it. You still have to maintain the old input’s behaviour while it’s deprecated, and decide what happens if a consumer supplies both the old and the replacement input. Does the new one take precedence, or should that be rejected? The warning alone doesn’t answer that. And a warning buried in a long pipeline log can still be missed, so it isn’t a substitute for communicating the retirement.
The other half of deprecation is knowing who you’re about to affect. Because every consumer pins with a Git ref, a code search across the organisation for the module’s source URL tells you which repositories declare which versions. That’s useful, but it isn’t the same as knowing what’s actually been applied. Before cutting a major, I want the repository list and, where possible, the deployed version. Before dropping support for an old major, I want to have spoken to the teams still using it.
That needs a stated support window. The policy I’d start with is supporting the current major and the previous major for at least twelve months after its successor is published. New features go into the current major; fixes for serious defects or security issues can be backported where necessary. Whatever the policy is, it belongs in the module’s README so nobody has to ask.
Gate: deprecated inputs warn before they’re removed, the retirement date or release is explicit, and you know who could be affected by the version you’re retiring.
The Terraform module lifecycle is the governance
Put the gates side by side and they make a fairly short list:
- Design: the interface makes sense to someone who didn’t write it.
- Test: the pipeline is green and the tests weren’t weakened without review.
- Review: the change type is declared and agreed.
- Release: the tag is protected, the notes say what changed, and major releases have been piloted.
- Consume: upgrades arrive as PRs with plans, and adoption is visible.
- Deprecate: nothing disappears without warning or an ownership decision.
None of these is complicated on its own. What makes them work is that each one hands something concrete to the next. The fixture written at design becomes the test. The change type declared at review becomes the version number. The version number becomes the Dependabot PR. When those handoffs are automatic, the module lifecycle stops depending on someone remembering, and that’s what makes a release something a team can actually depend on.
That’s where this series ends. Across these posts the thread has been the same: a Terraform platform is governed by its conventions and the machinery that enforces them, not by the tool itself.
But there’s a limit to what a well-run module lifecycle can guarantee. It governs the code you release and the changes consumers choose to take. It doesn’t guarantee that the infrastructure stays that way afterwards. Someone can still make a change directly in the Azure portal, and the next plan will only catch it if it touches something Terraform manages.
That’s where the other half of platform governance starts: drift detection and, more importantly, who owns fixing it.