Versioning and Publishing Private GIS Terraform Modules

Publishing a reusable PostGIS or tile-bucket module to a private registry without a disciplined semver contract is how a patch bump silently rebuilds a production spatial index or flips a bucket’s storage class across every consumer at once. This guide sits within Module Design Patterns, the part of Spatial IaC Architecture & Fundamentals that keeps a geospatial estate reproducible, and it covers semver-tagging spatial modules, publishing them to a Terraform Cloud private registry, an S3 module store, or git tags, pinning versions in consumers, and running the module CI that catches breaking changes before a tag ships.

Symptom Identification and Triage

The need for versioning discipline surfaces as specific failures, not as a vague desire for tidiness. Match the estate against these signals before designing the release flow:

  • A module change ripples into every consumer unannounced. A commit to a shared postgis module on a branch that consumers reference by ?ref=main changes production the moment it merges, with no plan review per consumer. Unpinned ref=main or ref=master sources are the signature of a module that is published without versioning.
  • A patch bump proposes destroying a spatial resource. terraform plan after bumping a module from 1.2.3 to 1.2.4 shows a -/+ aws_db_instance replacement or a force_new on a bucket. A destructive diff behind a patch label means the module violated semver — the change was breaking and mislabeled.
  • Two consumers resolve different module versions unpredictably. A ~> 1.2 constraint quietly pulls 1.9.0 into one workspace and 1.2.0 into another because they last ran terraform init weeks apart, so a PostGIS extension matrix diverges across the estate — the exact parity failure Module Design Patterns exists to prevent.
  • No provenance for a deployed module. An incident review cannot answer which module version provisioned a tile bucket because the source was a moving branch. Missing an immutable version tag in state is the signature that publishing has no release boundary.

The flow below shows where a semver tag sits between authoring a module and a consumer pinning it.

Publish-and-consume lifecycle for a versioned private GIS module A left-to-right pipeline. A module source repository feeds a CI stage running terraform validate, spatial integration tests, and a breaking-change diff check. On success a semantic-version git tag v1.4.0 is cut, which publishes an immutable artifact into a private registry. Two consumers, a PostGIS stack and a tile-bucket stack, each pin an exact version constraint from the registry, and the resolved version is recorded in the dependency lock file for provenance. Module repo postgis · tile-bucket Module CI validate + tflint spatial tests breaking-change check Semver tag v1.4.0 (immutable) Private registry TFC / S3 / git tags PostGIS stack version = "1.4.0" Tile-bucket stack version = "1.4.0" .terraform.lock.hcl provenance Author → test → tag → publish → pin An immutable semver tag is the release boundary; consumers pin it and record it in the lock file.

Prerequisites and Environment Assumptions

This guide assumes a small library of spatial modules — a postgis module and a tile-bucket module — that today live in a shared repo and are consumed by branch reference. To publish them with a version contract you will need:

  • Terraform >= 1.10.0 in both the module CI and the consumers, so the dependency lock file and registry protocol behave consistently.
  • The AWS provider pinned to ~> 5.60 inside each module’s versions.tf. A module that does not pin its own provider re-exports that ambiguity to every consumer.
  • A private registry target: a Terraform Cloud private registry (VCS-backed, semver tags become versions automatically), an S3 bucket serving module archives, or git tags consumed directly by ref=. All three are valid; the registry only changes the source string, not the semver discipline.
  • CI permissions to run terraform validate, execute an ephemeral integration test against a disposable AWS account or LocalStack, and push a git tag. Publishing must be gated on green CI so a tag never ships an untested spatial module.

Step-by-Step Remediation

Establish the release contract once, then every module follows it. The order matters: pin the module’s own inputs, prove it in CI, cut an immutable tag, then pin it downstream.

  1. Pin the module’s own provider and declare a clean interface. A published spatial module must fix its provider version and expose only typed inputs, so a consumer inherits no ambiguity. This is the postgis module’s version file and a validated input.

    # modules/postgis/versions.tf
    terraform {
      required_version = ">= 1.10.0"
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.60" # the module fixes its own provider floor
        }
      }
    }
    
    # modules/postgis/variables.tf — extension matrix is a required, validated input
    variable "postgis_version" {
      type        = string
      description = "Pinned PostGIS extension version, e.g. 3.4"
      validation {
        condition     = can(regex("^3\\.[0-9]+$", var.postgis_version))
        error_message = "postgis_version must be a 3.x release such as 3.4."
      }
    }
  2. Encode a breaking-change contract in CI. Run terraform validate, tflint, and a spatial integration test that stands up the module and asserts a representative ST_Intersects query plans against a GiST index. Then diff the module’s input and output surface against the last tag: a removed variable, a changed default that forces replacement, or a renamed output is a MAJOR change and must fail a MINOR/PATCH release.

    # module CI — fail the build if the interface changed incompatibly for a non-major bump
    terraform init -backend=false
    terraform validate
    tflint --minimum-failure-severity=error
    go test ./test/...        # terratest: applies the module, asserts spatial index health, destroys
  3. Cut an immutable semantic version tag. The tag is the release boundary. MAJOR for a breaking interface change (a removed input, a force_new on a spatial resource), MINOR for a backward-compatible feature (a new optional variable), PATCH for a fix that changes no interface. Never move a published tag.

    # only runs on green CI from the default branch
    git tag -a v1.4.0 -m "postgis module: add optional read-replica input (MINOR)"
    git push origin v1.4.0
  4. Publish to the private registry. For a Terraform Cloud private registry the pushed tag is discovered automatically; for an S3 store, package and upload the archive under a versioned key; for git-tag consumption the tag itself is the artifact.

    # S3 module store: immutable, version-addressed key
    tar -czf postgis-1.4.0.tgz -C modules/postgis .
    aws s3 cp postgis-1.4.0.tgz \
      s3://spatial-iac-modules/postgis/1.4.0/module.tgz \
      --content-type application/gzip
  5. Pin the exact version in every consumer. Consumers reference the registry address with an exact version, never a moving branch, so a plan is reproducible and provenance lands in the lock file.

    # consumers/gis-platform/main.tf
    module "postgis" {
      source  = "app.terraform.io/acme-geo/postgis/aws"
      version = "1.4.0"          # exact pin; bump deliberately via PR, never ~> across a major
      postgis_version = "3.4"
    }
    
    module "tile_bucket" {
      source  = "app.terraform.io/acme-geo/tile-bucket/aws"
      version = "2.1.0"
    }

Note: when the postgis module sets an RDS parameter such as shared_buffers, express it in AWS 8 kB block integer units — for example 524288 for 4 GiB (4 GiB ÷ 8 kB) — never as a percentage string like "25%". Bake the block-count arithmetic into the module so a MINOR release cannot ship a value the engine rejects; see How to Structure Terraform Modules for PostGIS.

Verification

Confirm the release is immutable, discoverable, and correctly pinned before consumers rely on it.

  1. Confirm the version resolves. From a consumer, run terraform init and inspect the resolved version and its provenance in the lock file:

    terraform init
    terraform providers lock
    grep -A2 "postgis" .terraform.lock.hcl   # asserts the exact version was recorded
  2. Confirm the tag is immutable. Attempt to re-tag and expect a rejection, proving a shipped version cannot silently change under consumers:

    git tag -a v1.4.0 -m "retag" 2>&1 | grep -q "already exists" && echo "immutable: OK"
  3. Confirm no destructive diff on a patch bump. Bump a consumer from 1.4.0 to a candidate 1.4.1 and run terraform plan; a PATCH release must produce an in-place update or no change, never a -/+ replacement of a PostGIS instance or tile bucket.

Publishing steps that make a version trustworthy A version is trustworthy when the tag is immutable, the changelog says what decisions changed rather than which files did, the examples in the repository are the ones tested, and the release is reachable by the same source address every environment uses. Skipping any of them produces a version consumers cannot adopt with confidence — most often the mutable tag, which turns a pinned reference into a moving one and reintroduces exactly the drift the pin existed to prevent. Immutable tag a moving tag is not a pin Changelog of decisions, not files callers care what changed for them Examples that are actually tested untested examples become support load One source address everywhere or environments diverge by path

Preventing Recurrence

Encode the release contract so an unversioned or mislabeled module cannot ship again.

  • Policy-as-code gate on sources. Fail any consumer plan whose module block uses a ref=main/ref=master source or omits a version argument, so moving-branch references cannot reach an environment.
  • Automated semver check in CI. Run the interface-diff step on every module pull request and block a MINOR/PATCH tag when a variable is removed or a default flips to force_new, enforcing the breaking-change discipline the Multi-Cloud Abstraction Patterns for Spatial Platforms rely on to swap provider implementations safely.
  • Registry as the only publish path. Restrict tag-creation and registry-push permissions to CI, so a human cannot publish a module that skipped tests.
  • Changelog per tag. Require a changelog entry naming the semver level and any migration step, so consumers upgrading a PostGIS or tile-bucket module across a MAJOR know exactly what breaks.

Frequently Asked Questions

What counts as a breaking (MAJOR) change in a spatial Terraform module?

Any change that alters the module’s contract or forces resource replacement: removing or renaming an input or output, changing a default so a plan proposes -/+ on a PostGIS instance or tile bucket, or raising the required provider or Terraform version floor. Backward-compatible additions are MINOR; fixes that touch no interface are PATCH.

Should consumers pin an exact version or use a `~>` range?

Pin an exact version for production spatial estates so plans are reproducible and provenance is unambiguous. A ~> 1.4 range can silently pull a new MINOR into one workspace and not another depending on when each last ran init, diverging the extension matrix across the estate. Bump the exact pin deliberately through a reviewed pull request.

Do I need Terraform Cloud, or can I publish with git tags or S3?

All three work. A Terraform Cloud private registry discovers semver tags automatically and enforces the module address format; an S3 store serves version-addressed archives; git tags are consumed directly via ref=v1.4.0. The registry only changes the source string — the semver tagging, CI gate, and consumer pinning discipline are identical.

How do I test a spatial module before tagging it?

Run an integration test (for example Terratest) that applies the module against a disposable account or LocalStack, asserts spatial correctness — a GiST index is present and a representative ST_Intersects query plans index-only — then destroys. Gate the tag on that test passing so an untested extension-matrix change never becomes a published version.

What counts as a breaking change in a module A module's contract is what it decides on the caller's behalf, so a breaking change is any change to those decisions — not merely a change to the input variables. Removing or renaming an input is obviously breaking. So is changing a default, because callers who did not set it get something different. So is changing what the module creates, since a caller who gets multi-zone deployment where they had single-zone has had their bill and their topology altered. And so is changing an output's meaning, because downstream configuration binds to it. Removing or renaming an input obviously breaking Changing a default callers who did not set it get something else Changing what is created the caller's topology and bill change Changing an output's meaning downstream configuration binds to it