Terraform modules that scale with your team, not against it
A Terraform module is how a team stops copy-pasting infrastructure. But a module that isn't versioned, reviewed and tested doesn't scale your team — it scales your blast radius. One careless change to a shared 'vpc' module and every environment that points at it inherits the bug on the next apply. The fix is to stop treating modules as shared folders and start treating them as products with releases.
This is the practical version: what makes a module reliable, how to release it through a proper pipeline, and how teams consume it — locally and in CI — straight from GitHub, pinned to a tag or, when security demands it, an immutable commit hash. No private registry required.
What makes a module reliable
Reliability starts before any pipeline. A module other teams can trust is small and does one thing — a network, a database, a service — not a 'platform' that does everything. It exposes a clear, documented set of inputs and outputs, ships sane defaults, and pins its own provider versions so it behaves the same today and in six months.
- Single responsibility — one module, one concern; compose them rather than nesting a monolith.
- Explicit interface — typed variables with descriptions and validation, and outputs other modules actually need.
- Pinned providers — required_providers with version constraints, so a provider release can't silently change behaviour.
- Examples and docs — an examples/ directory that is also what the tests run against.
- No surprises — no hard-coded names, regions or account IDs; nothing that reaches outside its inputs.
None of that survives a growing team unless two things are non-negotiable: every change is reviewed, and every change is tested against real, throwaway infrastructure. A module is the one piece of code whose bugs get multiplied by every consumer — it earns the strictest bar you have.
Versioning is the contract
As the organisation grows and new environments spin up — a sandbox here, a second region there — versioning stops being nice-to-have and becomes the contract between the module and everyone using it. Every consumer pins an explicit version; nobody tracks main. Semantic versioning then tells consumers what a bump means: a patch is safe, a minor adds, a major will make them change code.
Pinning is also how you move forward and back with confidence. Promotion to production is a version bump; a bad release is a bump back to the previous one. Without versions, 'roll back the module' really means 'go find which commit it was on last Tuesday'.
Consume it from GitHub — no registry needed
You don't need a private registry or Artifactory to share modules well. Reference the module directly by its Git source and a ref. The same source string works on a laptop and in CI; authentication is just a GitHub token — a PAT locally, the runner's token in CI — wired in once so HTTPS clones carry the credential.
# main.tf — pin to a released tag
module "vpc" {
source = "git::https://github.com/acme/tf-modules.git//modules/vpc?ref=v1.4.0"
cidr_block = "10.20.0.0/16"
azs = ["eu-west-1a", "eu-west-1b"]
}The credential is set once via git's insteadOf, so neither your code nor your state ever contains a secret — Terraform just clones over HTTPS as you:
# local + CI: let git use a token for github.com over HTTPS
git config --global url."https://oauth2:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"Immutability: pin a commit hash when it must stay locked
A tag is convenient, but it is movable: anyone with write access can re-point or force-push v1.4.0 to different code, and your next apply pulls it. For most modules that trade-off is fine. For the ones that must not change under you — anything security- or compliance-sensitive — pin the full commit hash instead. A commit can't be swapped: ref=<sha> always resolves to exactly the bytes you reviewed.
# locked to an exact, unswappable commit
module "kms" {
source = "git::https://github.com/acme/tf-modules.git//modules/kms?ref=3f9a1c4e8b7d6c5a4f3e2d1c0b9a8f7e6d5c4b3a"
}Release the module like a product
If consumers pin versions, something has to produce those versions reliably — a pipeline in the module repo. And it's worth being precise: a module isn't 'deployed'. It's validated, tested and published. Deployment is the consumer's job.
- On every pull request, run the static checks: terraform fmt -check, terraform validate, and tflint.
- Then test for real — stand up the module's example in a throwaway sandbox account, assert it (terraform test, or Terratest for richer checks), and destroy it. A module that has never been applied is untested.
- On merge to main, let GitVersion compute the next semantic version from the commit history, create the matching Git tag, and publish — announce the release on the team's Slack or Teams channel (and mirror to a registry if you happen to run one).
# .github/workflows/release.yml (sketch)
on: { pull_request: {}, push: { branches: [main] } }
jobs:
check:
steps:
- run: terraform fmt -check -recursive
- run: terraform validate
- run: tflint
- run: terraform test # applies examples in a sandbox, then destroys
release:
if: github.ref == 'refs/heads/main'
needs: check
steps:
- uses: gittools/actions/gitversion/execute@v3 # -> next semver
- run: gh release create "v${VERSION}"
- run: ./notify.sh "tf-modules ${VERSION} published" # Slack / TeamsThe exact tool doesn't matter — GitVersion, release-please and semantic-release all work. What matters is that a version only comes into existence after the change has been reviewed, validated and actually applied.
Now use it: from Terraform to Terragrunt
With a tested, tagged module, a plain Terraform project just pins it — the module block above is the whole story. That's fine for one or two environments. But once you run the same stack across dev, staging and prod, in one or more regions, copy-pasting that wiring per environment is exactly the duplication modules were meant to kill.
Terragrunt keeps environments DRY
Terragrunt wraps Terraform to remove that repetition: it generates the backend and provider config, keeps the module version in one place per stack, and lets each environment be a thin file that says 'this module, this version, these inputs'. Promotion up the chain becomes a one-line version bump.
# live/prod/eu-west-1/vpc/terragrunt.hcl
terraform {
source = "git::https://github.com/acme/tf-modules.git//modules/vpc?ref=v1.4.0"
}
include "root" { path = find_in_parent_folders() }
inputs = {
cidr_block = "10.30.0.0/16"
azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
}A layout that scales: project / environment / region
Lay the live repo out so the path is the address of the infrastructure. A boring-on-purpose shape is environment → region → component — adapt the levels to how your org actually thinks (some split by project, account or team first):
live/
├── root.hcl # remote state, provider, common tags
├── dev/
│ └── eu-west-1/
│ ├── vpc/terragrunt.hcl
│ └── eks/terragrunt.hcl
├── staging/
│ └── eu-west-1/ ...
└── prod/
├── eu-west-1/
│ ├── vpc/terragrunt.hcl
│ └── eks/terragrunt.hcl
└── eu-central-1/ ...Each leaf pins a module version, and the folder tells you exactly what runs where. Upgrading production is then a reviewed PR that changes one ref= from v1.4.0 to v1.5.0 — small, obvious, and trivial to revert.
Reliable, single-purpose modules; reviewed and tested on every change; versioned and published by a pipeline; consumed from GitHub by tag — or by immutable commit hash when it matters — and wired up with a Terragrunt layout that mirrors your estate. That is the difference between modules that scale with your team and modules that quietly scale against it.