Version Your Infrastructure Like Software | PulseSoft
PulseSoft

Version Your Infrastructure Like Software

Michael Emmanuel · August 6, 2026 · 12 min read

Introduction

The platform team pushed an update to the shared VPC module on a Tuesday morning. They didn't cut a release. They updated the module in place on the main branch, and all eleven consumers of that module (services running across development, staging, and production), silently picked up the change on their next terraform init. Two of those consumers had module.vpc.private_subnet_ids in their outputs. The new module version renamed that output to module.vpc.private_app_subnet_ids. Both consumers produced plan errors on their next apply. One of them was in the middle of a production deployment.

Infrastructure release management is the discipline of applying software release principles (versioning, changelogs, semantic contracts, and upgrade paths), to infrastructure code. It's what separates a platform team that can evolve shared modules safely from one that breaks consumers with every update. Most infrastructure teams understand why you version application libraries. Fewer apply the same reasoning to their Terraform modules, their CloudFormation stacks, or their AMIs. The consequences of not doing so are identical: broken consumers, unclear upgrade paths, and the accumulation of fear around making changes to anything shared.

This post covers the specific mechanisms that make infrastructure versioning work in practice.


Why Infrastructure Without Versioning Breaks Everyone Downstream

The application analogy is the clearest starting point: imagine if every time the team that maintains requests (the Python HTTP library) pushed a commit to their main branch, every consumer immediately ran the new code. No version pinning. No release notes. No deprecation window. A rename of a function argument would silently break every caller at the next import. Nobody ships libraries this way. But infrastructure teams ship modules this way constantly.

The core problem is the absence of a versioned interface contract. When a Terraform module lives at a git reference that can move (a branch name, a symlink to the latest directory in a monorepo, an unpinned registry reference), every consumer is implicitly on the bleeding edge of every change the module author makes. Breaking changes are immediate and universal.

The failure mode compounds in multi-environment setups. Without versioning, every environment (dev, staging, production), uses the same module version by definition (because there's only one version). A module change that looks safe in dev is implicitly deployed to production the next time anyone in that environment runs terraform apply. There's no concept of "promote this module version from staging to production after validation." There's just the module, and it's the same everywhere.

The second failure mode is the evolution trap. Without versioning, module authors can't make breaking changes safely. If renaming an output variable breaks eleven consumers, the author has two choices: don't rename it, or rename it and break eleven consumers simultaneously. This creates a ratchet toward accumulating backwards-compatible additions (new outputs, new optional variables), while never being able to clean up the interface. Modules grow. They never shrink. After two years, the interface is a museum of every decision made since the module was first written, none of which can be removed because removal would be breaking.

A concrete illustration: a healthcare client had a security group module that still exposed a var.enable_flow_logs variable that had been deprecated eighteen months earlier when the team moved flow log configuration to a separate module. They couldn't remove it because two consumer modules still passed it in, even though the module had ignored the value for a year and a half. With versioning, they could have cut a major version that removed the deprecated variable, notified consumers, and given them a migration window. Without it, the variable will outlive the engineers who created it.


AWS Deep Dive: The Mechanics of Infrastructure Versioning

Semantic Versioning for Terraform Modules

Semantic versioning applied to Terraform modules follows the same contract as software: MAJOR for breaking changes, MINOR for backwards-compatible additions, PATCH for backwards-compatible fixes.

The definitions need to be explicit for infrastructure:

MAJOR (breaking):

  • Removing or renaming an input variable
  • Removing or renaming an output
  • Changing a variable's type in a way that requires consumer updates
  • Changing a resource that requires replacement (adding a force_new attribute to an existing resource triggers a destroy/create cycle for all consumers)
  • Removing a resource from the module (causes that resource to be destroyed in consumer state)

MINOR (backwards-compatible addition):

  • Adding a new optional variable with a default
  • Adding a new output
  • Adding new resources that don't affect existing resources
  • Updating default values for optional variables (use caution: this can be functionally breaking even if it's syntactically backwards-compatible)

PATCH (fix):

  • Correcting a resource configuration that was wrong without changing the interface
  • Updating provider version constraints
  • Documentation and comment updates

The implementation for a Git-based module registry uses tags:

# Tag a new module release
git tag -a "v2.3.0" -m "Add task_role_arn output, support for additional secret ARNs"
git push origin v2.3.0

Consumers pin to a specific tag:

module "api_service" {
  source = "git::https://github.com/yourorg/terraform-modules.git//ecs-service?ref=v2.3.0"
  # ...
}

The ?ref=v2.3.0 pins the consumer to that exact tag. The module author can push v2.4.0 the next day and this consumer is unaffected until they explicitly update the ref. That's the contract.

For teams using Terraform's private module registry (available in Terraform Cloud or self-hosted via Terraform Enterprise, or via open-source alternatives like Terralist), the version constraint syntax is cleaner and enables version constraint expressions:

module "api_service" {
  source  = "app.terraform.io/yourorg/ecs-service/aws"
  version = "~> 2.3"  # Accept 2.3.x but not 2.4.0+
  # ...
}

The ~> 2.3 pessimistic constraint operator means "at least 2.3.0 but less than 3.0.0 and within the 2.x minor series" (wait, more precisely it means "at least 2.3.0 but less than 2.4.0" since it pins to the rightmost specified component. For accepting all patch and minor updates within a major version, use ~> 2.0 instead. The specific behavior of ~> in Terraform differs subtly from other ecosystems), verify the behavior matches your intent by testing with terraform version constraints before relying on it for production module pinning.

Changelogs as Upgrade Contracts

A version number tells a consumer whether they need to review before upgrading. A changelog tells them what specifically changed and what they need to do. Without one, version numbers are meaningless tags on an opaque blob of HCL.

The changelog format that works for infrastructure modules follows Keep a Changelog conventions adapted for IaC concerns:

# Changelog

## [3.0.0] - 2025-03-14

### Breaking Changes

- **Removed** `var.enable_enhanced_monitoring`, monitoring interval is now always 60s in
  production environments (determined by `var.environment`). Remove this variable from all
  module calls.
- **Renamed** output `db_instance_id` → `rds_instance_id` for naming consistency.
  Update all references in downstream modules and root configurations.

### Migration Guide

1. Remove `enable_enhanced_monitoring` from all module calls
2. Update output references from `module.rds.db_instance_id` to `module.rds.rds_instance_id`
3. Run `terraform plan` to verify no unexpected changes before applying

---

## [2.4.0] - 2025-02-01

### Added

- New output `parameter_group_name`, ARN of the DB parameter group for use in
  compliance reporting
- Optional `var.deletion_protection` (default: `true`), previously hardcoded to `true`

### Fixed

- Corrected CloudWatch alarm evaluation period from 1 to 2 minutes for error rate alarm
  (single-minute evaluation was producing false positives under brief load spikes)

The migration guide section is what separates a useful changelog from a release note. For major version bumps, a step-by-step migration procedure, written for someone who hasn't been following the module's development, reduces upgrade friction from "hours of archaeology" to "thirty minutes of mechanical changes."

AMI and Container Image Versioning

Infrastructure versioning extends beyond Terraform modules. AMIs and container base images are infrastructure artifacts that need the same release management discipline.

For AMI versioning in an EC2 Auto Scaling context, the common pattern is parameterizing the AMI ID in Terraform and tracking the current AMI version in a parameter store path that the CI/CD pipeline updates on each successful AMI build:

data "aws_ssm_parameter" "ami_id" {
  name = "/platform/amis/base-amazon-linux-2023/latest"
}

resource "aws_launch_template" "app" {
  name_prefix   = "${var.service_name}-"
  image_id      = data.aws_ssm_parameter.ami_id.value
  instance_type = var.instance_type
  # ...
}

The non-obvious AWS behavior here: when the SSM parameter value is updated (because a new AMI was built), a terraform plan will show a change to the launch template, specifically the image_id field updating to the new AMI ID. That plan change will trigger a launch template version update. But it does not automatically update running instances in the Auto Scaling Group. A new launch template version is not automatically deployed; you need either a scheduled instance refresh or an explicit aws_autoscaling_group_instance_refresh resource to roll the change to running instances.

If your pipeline updates the SSM parameter and then runs terraform apply expecting that to deploy the new AMI to production, it doesn't. The launch template updates. The next scale-out event will launch new instances with the new AMI. Existing instances remain on the old AMI until the next scheduled refresh or replacement event. Plan your AMI deployment strategy explicitly, don't assume terraform apply on a new AMI ID is a deployment.


Enjoying this so far?

Join thousands of engineers receiving practical cloud, DevOps, Kubernetes, Infrastructure and AI engineering insights.

No spam. Unsubscribe anytime.

Tradeoffs & Decision Framework

The overhead of formal infrastructure release management is real, and the right level of rigor depends on the team's size and the number of module consumers.

Minimal viable versioning (1–3 engineers, few module consumers): Git tags on module updates, with a commit message convention that indicates breaking changes (BREAKING CHANGE: renamed output x to y). No formal registry, no changelog tooling. The benefit-to-overhead ratio for more structured tooling is low at this scale. The critical discipline is pinning consumer ref values to specific tags rather than branches.

Structured versioning (4+ engineers, multiple teams consuming shared modules): Formal CHANGELOG.md maintained per module, semantic version tags enforced via CI (block tagging a new minor version if the changelog doesn't include a [MINOR] entry), and a private Terraform module registry for version constraint expressions. The coordination overhead scales with consumer count: a single engineer can hold module dependencies in their head; four engineers across three teams cannot.

Mature release management (platform teams, regulated environments): Formal release process with a deprecation period for major versions. Old major versions are maintained for a defined window (e.g., 90 days) after the new major version releases, security patches applied to the old major, no new features. Consumers are required to migrate within the deprecation window. This mirrors how AWS manages SDK major versions and how Hashicorp manages provider major versions. It's the only model that works when the consumer base is large enough that coordinated simultaneous migration is impossible.

What breaks at every level without versioning: Multi-environment promotion. If dev, staging, and production consume the same unversioned module, there's no mechanism for promoting a tested module change from dev → staging → production. You can't say "this module change has been running in dev for two weeks without issues, now promote it to staging." With versioning, that promotion is explicit: update the ref in the staging configuration. Without it, all environments share the same code and there's no way to validate a module change at lower risk before it reaches production.


Lessons From the Field

1. The first breaking module change without a changelog costs more in engineer time than writing the changelog would have. At a fintech client, a platform engineer renamed a VPC module output from private_subnets to private_app_subnet_ids for clarity. No changelog. No migration guide. The rename broke seven downstream modules and four root configurations. Each team spent thirty to sixty minutes diagnosing why their pipeline had failed. Total remediation time across teams: approximately six hours. The changelog and migration guide would have taken thirty minutes to write and would have reduced remediation to "read the migration guide, make the change."

2. Pinning to a branch name instead of a tag is a slow-motion incident waiting to happen. Inherited a codebase where every module reference used ?ref=main. The platform team pushed a module update that required a Terraform 1.5+ feature. Three consumer pipelines were running Terraform 1.4. They all started failing on their next terraform init. The consumer engineers had no idea why: they hadn't changed their code. From their perspective, nothing had changed. From the platform team's perspective, they had pushed an update to main. Pinning to tags would have contained the change to consumers who explicitly upgraded their reference.

3. Default value changes are breaking changes in practice, even when they're not in theory. Updated an RDS module's var.backup_retention_period default from 7 to 30 days for a fintech client. Backwards-compatible by definition, any consumer explicitly setting the variable is unaffected, and consumers using the default get better backup retention. What we hadn't accounted for: the change caused a plan that modified the backup_retention_period attribute on every RDS instance using the module default. Three production database instances showed a "will be modified" in the next plan. An engineer saw the modification, didn't know why it was happening, and flagged it as unexpected drift. We should have bumped the minor version and documented the default change explicitly. "Backwards-compatible addition" changes that affect the plan output for existing consumers should always be documented.

4. Infrastructure deprecation windows work better with tooling than with memory. Ran a deprecation of v1.x of a security group module after releasing v2.0 at a growth-stage SaaS company. Sent a Slack announcement. Two months later, four services were still on v1.x. The engineers responsible hadn't prioritized the migration because there was no automated reminder. Added a Config rule that flagged resources created by v1.x module invocations (identified by a module version tag applied to every resource the module created). The Config findings were in the weekly security review. Within three weeks, all four services had migrated.

5. AMI versioning without automated instance refresh is release management without deployment. Built an AMI pipeline for an enterprise client that built new AMIs weekly with security patches, pushed the AMI ID to SSM Parameter Store, and ran terraform apply to update launch templates. The CISO asked for a report on "the age of running EC2 instances." The answer was that most instances were four to six months old: the new AMIs were in the launch template but instances were only replaced when Auto Scaling scaled out. We implemented an aws_autoscaling_group_instance_refresh resource with a scheduled trigger. Actual AMI deployment followed AMI versioning for the first time.


Final Thoughts

The infrastructure versioning conversation is catching up to where application versioning has been for a decade. Terraform's private module registry, the adoption of GitOps patterns for infrastructure delivery, and the growth of platform engineering as a discipline are all pushing teams toward treating infrastructure modules as versioned artifacts with release contracts rather than shared files with unconstrained mutability.

What lags behind is the deprecation and migration story. Application library authors have decades of experience with deprecation notices, migration guides, and compatibility windows. Infrastructure platform teams are still figuring out how to manage major version transitions across large consumer bases, how long to support old major versions, how to enforce migration deadlines, and how to communicate breaking changes to teams who are focused on their own delivery work.

The teams that get this right will treat a major module version bump with the same coordination and communication overhead as a major API version change: documented migration guide, consumer notification, migration window, and a support period for the old version. That discipline is available to any team willing to apply it. The tools exist. The missing piece is usually the decision to invest in it before the first breaking change causes a painful lesson.

Infrastructure release management is the kind of platform discipline we build from the ground up at PulseSoft. If your module library has grown to the point where making changes feels risky, let's talk.


Key Takeaways

  • Unversioned Terraform modules are libraries without version pinning: every consumer is silently on the bleeding edge of every change. A branch ref that moves is not a version contract. Only git tags and registry versions with semantic versioning create a stable consumer interface.
  • Infrastructure versioning uses the same MAJOR/MINOR/PATCH semantics as software, with infrastructure-specific definitions. Removing or renaming a variable or output is MAJOR. Adding a new optional variable with a default is MINOR. Fixing an incorrect resource configuration without interface changes is PATCH.
  • Changelogs for infrastructure modules must include migration guides for breaking changes. A version number tells consumers whether to review; a migration guide tells them what to do. Without it, MAJOR version upgrades require archaeology instead of execution.
  • Default value changes that modify the plan output for existing consumers are functionally breaking, even when they're backwards-compatible syntactically. Document them in the changelog and consider whether they warrant a MINOR version bump even when no interface has changed.
  • Updating an AMI ID in SSM Parameter Store and running terraform apply updates the launch template but does not deploy the new AMI to running instances. Instance refresh must be triggered explicitly, via aws_autoscaling_group_instance_refresh or a scheduled instance refresh, to replace running instances with the new AMI.
  • Multi-environment promotion is impossible without versioning. Without the ability to pin dev, staging, and production to different module versions, there is no mechanism for validating a module change at lower risk before it reaches production. All environments share the same code and any module update is immediately universal.
  • Deprecation windows need enforcement tooling, not Slack announcements. A Config rule tagging resources by module version, with findings surfaced in security reviews, turns a forgotten migration into a visible compliance item. Memory doesn't scale. Tooling does.

Get insights like this in your inbox

Join thousands of engineers receiving practical cloud, DevOps, Kubernetes, Infrastructure and AI engineering insights.

No spam. Unsubscribe anytime.

← Back to Blog