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.

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.