Home / Networking

The Perfect Terraform Module: File Structure, Versioning, Packaging & GitHub Actions CI | 2026

The Perfect Terraform Module: File Structure, Versioning, Packaging & GitHub Actions CI | 2026

A production-grade Terraform module is a fixed file layout (main.tf, variables.tf, outputs.tf, versions.tf, README.md, CHANGELOG.md, INSTRUCTIONS.md, examples/) versioned with SemVer git tags, packaged for consumption via pinned git refs or a registry, and guarded by a GitHub Actions pipeline running fmt, validate, tflint, and terraform-docs on every pull request. This guide builds exactly that module — a small terraform-google-network module — from empty directory to automated release.

By Mateusz Chmielewski · Aug 18, 2026 · 16 min read

What Is a Production-Grade Terraform Module?

A Terraform module is a reusable, versioned bundle of infrastructure — but a production-grade module is a product, not a folder of .tf files. It has a stable public contract (typed, validated input variables and documented outputs), a pinned provider floor (versions.tf with required_providers, never a configured provider block), human-facing docs (README generated by terraform-docs, a CHANGELOG following Keep a Changelog), consumer-facing upgrade guidance (INSTRUCTIONS.md describing how to move between provider and module versions), working examples that double as integration tests, and a CI pipeline that rejects anything that breaks fmt, validate, or lint. Versioned releases are git tags following SemVer, and consumers reference the module by tag — never by branch.

Think of a production Terraform module like a well-made electrical appliance. The input variables are the plug — a standardized shape (types and validation) that refuses to connect the wrong way. The outputs are the manufacturer's label — documented ratings you can rely on. The CHANGELOG is the model-year sheet telling you what changed, INSTRUCTIONS.md is the manual for servicing it yourself, and the GitHub Actions pipeline is the factory QA line: nothing ships without passing every station. A random folder of .tf files is a prototype on a workbench — fine at home, unacceptable in a store.

ConceptExplanationWhen to use
Module ContractThe public surface — variable names, types, defaults, validation rules, and output names. Everything else is private implementation.Treat any contract change as a potential breaking change; that decision drives your SemVer bump.
versions.tf / required_providersDeclares the minimum Terraform CLI and provider versions the module supports. Never contains credentials or a configured provider block.Always — it is how consumers' `terraform init` resolves compatible providers and how you communicate upgrade floors.
SemVer Git TagsReleases are annotated tags v1.2.3; consumers pin with `source = "git::...?ref=v1.2.3"` or a registry version constraint.Every merge to main that ships to consumers gets a tag; MAJOR for contract breaks, MINOR for additions, PATCH for fixes.
INSTRUCTIONS.md (Upgrade Guide)A self-service runbook — how to bump the google provider floor, run `terraform init -upgrade`, migrate state with `terraform state mv` if resources were renamed.Whenever a release changes the provider floor or moves resources; turns upgrade tickets into copy-paste steps.
Examples as Testsexamples/basic and examples/complete are root modules that call your module; CI runs init + validate + plan against them.They are both documentation and the cheapest possible integration test — if the example breaks, the module is broken.

Why Invest in Module Structure Instead of Shipping a .tf Folder?

Unstructured modules rot silently. There is no versions.tf, so one consumer's provider upgrade breaks another team's plan. Variables have no validation, so a typo'd CIDR becomes a 40-minute apply failure. There is no changelog, so upgrading from 'the version from March' to 'current main' is an act of faith — so nobody upgrades, and the fleet freezes on an ancient provider. Every consumer pins to a branch or copies the code, and the module author gets pinged on Slack for questions a README should answer.

The structure in this guide converts the module from shared code into a maintained product: the file layout makes every concern discoverable, SemVer tags plus CHANGELOG.md make upgrades an informed decision, INSTRUCTIONS.md makes provider bumps self-service, examples make usage copy-pasteable, and the GitHub Actions pipeline makes quality non-negotiable. The same discipline scales from a single module to a whole internal library — pair it with opinionated defaults like the [GCP organization policies module](/tutorial/gcp-organization-policies-defaults-terraform-module) and compose modules into bigger architectures like a [Shared VPC with Terraform](/tutorial/shared-vpc-terraform).

FeaturethisServicealtAaltB
Consumer pinningGit tag or registry version (immutable)Branch ref (changes under your feet)Copy-pasted code (never updated)
Upgrade safetyCHANGELOG + INSTRUCTIONS.md per releaseDiff main and hopeFull rewrite when drift hurts enough
Provider compatibilityrequired_providers floor, tested in CIWhatever the author's laptop hadFrozen at copy time, rots immediately
Documentationterraform-docs README + runnable examplesStale hand-written READMERead the code
Quality gatefmt/validate/tflint/docs on every PRIt planned fine on my machineFind out in production

Prerequisites

  • Terraform CLI 1.5+ installed locally
  • A GitHub repository for the module (public or private) and `gh` CLI authenticated
  • Basic familiarity with `terraform init/plan/apply` and git tagging
  • For CI: GitHub Actions enabled on the repo (free tier is sufficient)
  • Optional but recommended: `tflint` and `terraform-docs` installed locally

Step-by-Step Guide

Step 1: Lay Out the Standard File Structure

Create the canonical layout HashiCorp mandates and the registry enforces. We build `terraform-google-network-mini` — a module that creates a VPC and one subnet — because it is small enough to see every file's role without noise. The layout is a shared language. Any Terraform engineer who opens the repo knows exactly where inputs, outputs, and version constraints live — which is why the Terraform Registry refuses modules that deviate from it.

terraform-google-network-mini/
├── main.tf            # resources: the VPC and subnet
├── variables.tf       # inputs: typed + validated contract
├── outputs.tf         # outputs: stable public attributes
├── versions.tf        # terraform + required_providers floors
├── README.md          # terraform-docs generated docs
├── CHANGELOG.md       # Keep a Changelog release notes
├── INSTRUCTIONS.md    # self-service upgrade runbook
├── examples/
│   ├── basic/         # minimal usage
│   └── complete/      # every knob turned
└── .github/workflows/ # CI pipeline (step 7)

# versions.tf — floors only, NEVER a configured provider block
terraform {
  required_version = ">= 1.5"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 5.0, < 7.0"
    }
  }
}

Step 2: Define the Contract — Validated Variables and Typed Outputs

Write variables.tf with types, descriptions, defaults, and validation blocks, plus outputs.tf exposing only what consumers legitimately need. This contract is what SemVer will later protect. Validation fails fast at plan time with your message, instead of slow at apply time with a GCP API error. Outputs are promises — renaming one is a breaking change, so you only expose attributes you are willing to support for years.

variable "project_id" {
  description = "GCP project ID where the network is created."
  type        = string
  nullable    = false
}

variable "network_name" {
  description = "Name of the VPC network."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,62}

chmielewski.dev | GCP Engineering & Architecture Blueprints

Insights, guides, and architectural blueprints for creators shipping on Google Cloud Platform.

quot;, var.network_name)) error_message = "network_name must be 2-63 chars of lowercase letters, digits, hyphens." } } variable "subnet_cidr" { description = "Primary IPv4 range for the subnet." type = string validation { condition = can(cidrhost(var.subnet_cidr, 0)) error_message = "subnet_cidr must be a valid IPv4 CIDR block." } } # outputs.tf — expose attributes, not whole resources output "network_id" { description = "Fully qualified ID of the VPC network." value = google_compute_network.this.id } output "subnet_self_link" { description = "Self link of the primary subnet." value = google_compute_subnetwork.this.self_link }

Step 3: Version It — CHANGELOG.md, SemVer, and Git Tags

Adopt Keep a Changelog for CHANGELOG.md and Semantic Versioning for releases. Cut the first release as an annotated git tag v1.0.0 and publish a GitHub release. The tag is the immutable artifact consumers pin to; the changelog is how they decide whether upgrading is safe. Without both, 'latest' is the only version — and latest is a breaking change waiting to happen.

# CHANGELOG.md
# Changelog
All notable changes to this module are documented here.
The format follows Keep a Changelog and the module adheres to Semantic Versioning.

## [Unreleased]

## [1.0.0] - 2026-08-18
### Added
- Initial release: VPC network + regional subnet with flow logs.
- Validated inputs: project_id, network_name, subnet_cidr.
- INSTRUCTIONS.md upgrade runbook and examples/basic + examples/complete.

# Cut and publish the release
git add -A && git commit -m "chore: release v1.0.0"
git tag -a v1.0.0 -m "v1.0.0"
git push origin main --follow-tags
gh release create v1.0.0 --notes-from-tag

Step 4: Write INSTRUCTIONS.md — the Self-Upgrade Runbook

Author the file that lets consumers upgrade between module and provider versions without Slack-pinging you: how to change the ref, refresh providers, preview, and migrate state if resources moved. The number-one reason fleets rot on old module versions is fear — nobody knows what an upgrade will do. A concrete runbook converts upgrades from a risky exploration into a checklist, and keeps it working when you are on vacation.

# INSTRUCTIONS.md (excerpt) — Self-Service Upgrade Guide

## Upgrading the module version
1. Read CHANGELOG.md for every version between yours and the target.
2. Update the source ref:
   source = "git::https://github.com/org/terraform-google-network-mini.git?ref=v2.0.0"
3. Refresh providers and modules:  terraform init -upgrade
4. Preview:  terraform plan   (expect ONLY the changes listed in the changelog)
5. Apply during your normal change window:  terraform apply

## Upgrading the Google provider floor (e.g. 5.x -> 6.x)
1. Use module version v2.x or newer (v1.x pins provider < 6.0).
2. In YOUR root module, set:  google = { version = "~> 6.0" }
3. terraform init -upgrade && terraform plan
4. Provider 6.x renames google_compute_network.auto_create_subnetworks
   behavior — if plan shows subnet diffs you did not intend, stay on 5.x
   and open an issue with the plan output.

## If resources were renamed between module versions
terraform state mv module.net.google_compute_network.this \
                    module.net.google_compute_network.vpc

Step 5: Add Examples That Double as Integration Tests

Create examples/basic (the 5-line quickstart) and examples/complete (every input exercised). Both are real root modules that pin the module by relative path locally and by git ref in docs. Examples are the only documentation guaranteed to stay correct — CI runs them, so a broken example blocks the merge. They are also where consumers copy from, so what you ship there is what production gets.

# examples/complete/main.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    google = { source = "hashicorp/google", version = "~> 6.0" }
  }
}

provider "google" {
  project = var.project_id
  region  = "europe-west1"
}

module "network" {
  source = "../.."   # in consumer docs: git::https://...?ref=v1.0.0

  project_id   = var.project_id
  network_name = "example-complete"
  subnet_cidr  = "10.10.0.0/24"
}

output "network_id" {
  value = module.network.network_id
}

Step 6: Package for Consumption — Git Refs and Registry Constraints

Document the two supported consumption patterns — pinned git source for private modules and registry-style version constraints — and verify a fresh consumer can init against the tagged release. Packaging is the trust boundary. A pinned tag means the consumer's plan today equals their plan next month; a registry constraint like `~> 1.2` deliberately accepts patches. Both are valid — the module's job is to make the choice explicit and documented.

# Consumer root module — pattern A: pinned git tag (private repos)
module "network" {
  source = "git::https://github.com/org/terraform-google-network-mini.git?ref=v1.0.0"

  project_id   = "my-project"
  network_name = "prod-core"
  subnet_cidr  = "10.0.0.0/20"
}

# Pattern B: registry module with SemVer constraint (public/private registry)
# module "network" {
#   source  = "org/network-mini/google"
#   version = "~> 1.0"   # accepts 1.x patches and minors, never 2.0
#   ...
# }

# Verify from a clean directory
terraform init
terraform plan

Step 7: Automate Quality and Releases with GitHub Actions

Add the pipeline: on every PR — fmt check, init+validate against both examples, tflint, and terraform-docs diff check. On tag push — auto-create the GitHub release with changelog notes. Humans forget; pipelines don't. Every rule in this article — formatting, valid config, lint, docs in sync — is enforced on every pull request, so 'it worked on my machine' stops being a failure mode and releases become a single tag push.

# .github/workflows/ci.yml
name: module-ci
on:
  pull_request:
  push:
    tags: ["v*"]

permissions:
  contents: write

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.0

      - name: Format check
        run: terraform fmt -check -recursive

      - name: Validate examples
        run: |
          for dir in examples/*/; do
            terraform -chdir="$dir" init -backend=false
            terraform -chdir="$dir" validate
          done

      - uses: terraform-linters/setup-tflint@v4
        with:
          tflint_version: v0.53.0

      - name: TFLint
        run: tflint --format compact

      - name: terraform-docs drift check
        uses: terraform-docs/[email protected]
        with:
          working-dir: .
          output-file: README.md
          output-method: inject
          git-push: "false"

  release:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Publish release
        run: gh release create "$GITHUB_REF_NAME" --generate-notes
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Verification & Health Check

Best Practices

  • Declare Providers, Never Configure Them
  • Pin Consumers to Tags, Not Branches
  • Validate at the Contract Boundary
  • Release Notes Are Part of the Release

Common Mistakes

  • {"errorCode":"PROVIDER_CONFIGURED_IN_MODULE","symptoms":"Consumers see 'Module ... does not support overriding provider configuration' or silent cross-project resource creation.","rootCause":"A provider block with project/credentials was left inside the module, conflicting with the root module's provider inheritance.","fixCommand":"grep -rn \"^provider\" --include=\"*.tf\" . | grep -v examples/\n","code":"# Remove from the module entirely; keep only:\nterraform {\n required_providers {\n google = { source = \"hashicorp/google\", version = \">= 5.0, < 7.0\" }\n }\n}\n","language":"hcl","filename":"versions.tf","prevention":"The CI tflint step with the google ruleset plus a grep check for provider blocks outside examples/."}
  • {"errorCode":"BREAKING_CHANGE_IN_MINOR","symptoms":"Consumer upgrades 1.3 -> 1.4 and plan explodes with 'Unsupported argument' or resource replacement.","rootCause":"A variable was renamed or a default changed in a MINOR bump — the SemVer contract was broken, so trust in upgrading dies.","fixCommand":"git diff v1.3.0..v1.4.0 -- variables.tf outputs.tf\n","code":"# CHANGELOG.md — breaking changes demand a MAJOR and a migration note\n## [2.0.0] - 2026-09-01\n### Changed\n- BREAKING: variable `cidr` renamed to `subnet_cidr`.\n Migration: rename the argument; no state impact.\n","language":"markdown","filename":"CHANGELOG.md","prevention":"A PR checklist line — 'diff of variables.tf/outputs.tf reviewed for breaking changes; bump level matches CHANGELOG'."}
  • {"errorCode":"TAG_WITHOUT_RELEASE","symptoms":"Consumers' init fails: 'Failed to download module ... reference not found: v1.2.0'.","rootCause":"The release commit was pushed but the annotated tag stayed local (`git push` without `--follow-tags`), or the tag used a different name than the CHANGELOG heading.","fixCommand":"git push origin v1.2.0\ngh release create v1.2.0 --generate-notes\n","code":"# Tag name == CHANGELOG heading == GitHub release, always with a v prefix\ngit tag -a v1.2.0 -m \"v1.2.0\"\ngit push origin main --follow-tags\n","language":"bash","filename":"release.sh","prevention":"Let CI create the release from the tag (step 7) and add a CI assertion that the pushed tag exists in CHANGELOG.md."}
  • {"errorCode":"README_DOCS_DRIFT","symptoms":"README inputs table lists variables that no longer exist; consumers copy dead examples.","rootCause":"Hand-edited README sections drift from variables.tf within a few PRs; nobody diffs docs against code.","fixCommand":"terraform-docs markdown table . --output-file README.md --output-method inject\n","code":"# CI guard (fails the PR if README is stale)\n- name: terraform-docs drift check\n uses: terraform-docs/[email protected]\n with:\n working-dir: .\n output-file: README.md\n output-method: inject\n git-push: \"false\"\n","language":"yaml","filename":".github/workflows/ci.yml","prevention":"Generate, never hand-write, the inputs/outputs tables — and fail CI on diff."}
  • {"errorCode":"VALIDATE_NEEDS_BACKEND","symptoms":"CI job dies at 'Initializing the backend...' with credentials errors, or 'backend configuration changed'.","rootCause":"terraform validate requires init, and init tried to configure a remote backend the CI runner has no access to — but modules and examples need no state at all.","fixCommand":"terraform -chdir=examples/complete init -backend=false\nterraform -chdir=examples/complete validate\n","code":"- name: Validate examples\n run: |\n for dir in examples/*/; do\n terraform -chdir=\"$dir\" init -backend=false\n terraform -chdir=\"$dir\" validate\n done\n","language":"bash","filename":".github/workflows/ci.yml","prevention":"Standardize on `-backend=false` in all module CI; run real plans only in dedicated, credentialed test projects."}

Cost Analysis

Featuremetriccost1kcost10kcost100kcost1m
Terraform CLI + module development$0.00$0.00$0.00$0.00
GitHub Actions minutes (public repo)$0.00$0.00$0.00$0.00
GitHub Actions minutes (private repo, 2,000 free/mo)$0.00$0.00$0.00~$0-8/mo if over free tier
terraform-docs + tflint (open source)$0.00$0.00$0.00$0.00
Terraform private registry (HCP Terraform, optional)$0.00 (free tier)$0.00plan-basedplan-based

References

Browse all tutorials