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
postgismodule on a branch that consumers reference by?ref=mainchanges production the moment it merges, with no plan review per consumer. Unpinnedref=mainorref=mastersources are the signature of a module that is published without versioning. - A patch bump proposes destroying a spatial resource.
terraform planafter bumping a module from1.2.3to1.2.4shows a-/+ aws_db_instancereplacement or aforce_newon 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.2constraint quietly pulls1.9.0into one workspace and1.2.0into another because they last ranterraform initweeks 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.
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.0in both the module CI and the consumers, so the dependency lock file and registry protocol behave consistently. - The AWS provider pinned to
~> 5.60inside each module’sversions.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 thesourcestring, 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.
-
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
postgismodule’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." } } -
Encode a breaking-change contract in CI. Run
terraform validate,tflint, and a spatial integration test that stands up the module and asserts a representativeST_Intersectsquery 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 aMAJORchange and must fail aMINOR/PATCHrelease.# 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 -
Cut an immutable semantic version tag. The tag is the release boundary.
MAJORfor a breaking interface change (a removed input, aforce_newon a spatial resource),MINORfor a backward-compatible feature (a new optional variable),PATCHfor 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 -
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 -
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
postgismodule sets an RDS parameter such asshared_buffers, express it in AWS 8 kB block integer units — for example524288for 4 GiB (4 GiB ÷ 8 kB) — never as a percentage string like"25%". Bake the block-count arithmetic into the module so aMINORrelease 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.
-
Confirm the version resolves. From a consumer, run
terraform initand 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 -
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" -
Confirm no destructive diff on a patch bump. Bump a consumer from
1.4.0to a candidate1.4.1and runterraform plan; aPATCHrelease 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
moduleblock uses aref=main/ref=mastersource or omits aversionargument, 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/PATCHtag when a variable is removed or a default flips toforce_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
MAJORknow 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.
Related
- Module Design Patterns — the interface, layering, and guardrail patterns these published modules implement.
- Multi-Cloud Abstraction Patterns for Spatial Platforms — versioned modules as the seam for swapping provider implementations.
- How to Structure Terraform Modules for PostGIS — the internal structure of the stateful spatial module you publish here.
- Spatial IaC Architecture & Fundamentals — the architectural context this release discipline sits within.