TL;DR
A module directory inside your live repo is a convenience that becomes a liability within six months. Separate repos, separate release cycles: version your modules with semantic-release git tags, gate every change with terraform fmt (not validate — more on why), auto-generate docs with terraform-docs, and scan for misconfigurations with Trivy or tfsec. Consumers pin to a tag. Dependabot opens PRs when new versions drop. The whole lifecycle runs identically on GitHub Actions and GitLab CI.
The modules/ directory inside the live repo
Every team starts there. One repo, easy to clone, modules live next to the environments that consume them. I did it too.
By month three, the module changes and live deployments were mixed in the same PR. You couldn’t tell from the git log whether a change was a module refactor or an environment configuration tweak. You couldn’t pin consumers to a stable version because there was no version — the module was just a directory you pointed at with a relative path. And you definitely couldn’t test the module independently, because it was coupled to the provider configuration of whatever environment was co-located with it.
The fix is obvious once you’ve felt the pain: modules in their own repository, released on their own cadence, consumed via pinned git tags.
See hagzag/tf-modules for the public reference implementation this series uses.

The module repo layout
Flat structure. One directory per module. Everything else follows from that.
tf-modules/
├── .releaserc.yml # Semantic-release config
├── .github/
│ └── workflows/
│ └── main.yml # Change detection → fmt → docs → release
├── acm/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── README.md # Auto-generated by terraform-docs
├── regional-info/
│ ├── main.tf
│ └── outputs.tf
└── role-validator/
├── main.tf
├── outputs.tf
└── variables.tf
The CI pipeline detects which directories contain changed .tf files, builds a matrix, and runs validation and docs generation per changed directory. One release covers the entire repo.
Semantic-release: unified versioning across all modules
The repo uses a single semantic-release config that tags the entire repo at once:
# .releaserc.yml
branches:
- main
- name: 'dev'
prerelease: true
plugins:
- '@semantic-release/commit-analyzer'
- '@semantic-release/release-notes-generator'
- '@semantic-release/github'
A feat: commit on main bumps the minor version (v1.1.0). A fix: bumps patch (v1.0.4). A feat!: or BREAKING CHANGE: in the commit body bumps major.
Pre-releases from the dev branch produce v1.1.0-dev.1. You can test module changes in a staging environment before promoting to main:
# Staging environment testing a pre-release
terraform {
source = "git::https://github.com/hagzag/tf-modules//eks?ref=v1.1.0-dev.1"
}
When you merge to main, v1.1.0 is tagged. The pre-release is gone; consumers on main pick up the stable version.
The tradeoff of unified repo versioning: a change to acm/ bumps the version for eks/ even if nothing changed there. In practice, for a coherent platform module library, module changes are coordinated anyway. If your modules are truly independent, per-module repos (or a monorepo with per-module release configs) are an option — but the operational overhead of multiple repos and release pipelines adds up quickly.
The GitHub Actions pipeline
Three jobs: detect → validate → docs → release.
# .github/workflows/main.yml (simplified)
jobs:
find_changed_directories:
outputs:
matrix: ${{ steps.detect.outputs.matrix }}
steps:
- uses: tj-actions/changed-files@v44
# Produces JSON array of directories with changed .tf files
validate:
needs: find_changed_directories
strategy:
matrix:
directory: ${{ fromJson(needs.find_changed_directories.outputs.matrix) }}
steps:
- uses: hashicorp/setup-terraform@v3
- name: fmt check
run: terraform fmt -check
working-directory: ${{ matrix.directory }}
# Note: terraform validate is intentionally absent — see below
docs:
needs: validate
if: github.base_ref == 'main'
steps:
- uses: terraform-docs/gh-actions@v1
# Injects generated docs into README.md, pushes back to PR branch
release:
needs: validate
steps:
- run: npx semantic-release
ℹ️ Why no
terraform validate?Modules in this repo don’t carry
terraform { required_providers {} }blocks — that would force every consumer onto the same provider version. Without a backend and provider init,terraform validatefails with “no provider installed.” The formatting check (fmt -check) catches the structural issues that actually matter. Validate runs in the live repo, not the module repo.
The GitLab CI shared template
For GitLab-hosted module repos, the shared-ci library provides a drop-in pipeline template:
# In your module repo .gitlab-ci.yml
include:
- project: 'example-group/shared-ci'
ref: main
file: 'tf-versioning-semantic-release.gitlab-ci.yml'
stages:
- test
- deploy
The included template handles workflow rules (run on push to dev/main, on MR for .tf changes, never on tags), validation, and semantic-release. It also writes a default .releaserc.yml if none exists — so new module repos get CI for free with zero config.
One important detail: GIT_DEPTH: 0 is required. Semantic-release walks the full commit history to determine the next version. A shallow clone breaks it silently in the worst way — the release runs but produces the wrong version.
Security scanning: tfsec and Trivy
Catching a misconfigured S3 bucket at the module level is cheaper than catching it in a prod plan. Add scanning to the validate job:
- name: Run Trivy (IaC scan)
uses: aquasecurity/trivy-action@master
with:
scan-type: 'config'
scan-ref: ${{ matrix.directory }}
severity: 'HIGH,CRITICAL'
exit-code: '1'
ℹ️ tfsec → Trivy migration
tfsecwas the standard tool for Terraform security scanning through 2023. In 2024, Aqua Security merged tfsec’s functionality into Trivy — same findings, single binary for IaC + container + SCA scanning. New setups should use Trivy. Both are still valid;tfsecstill works and receives updates, but Trivy is the consolidated path forward.
Start with exit-code: '0' to see findings without blocking releases. Tighten to '1' once the team has reviewed the baseline.
Dependabot for consumer drift detection
In the tf-live repo (or any consumer), add Dependabot config:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "terraform"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
Dependabot scans every terragrunt.hcl source block for git tags, checks for newer tags on the referenced repo, and opens a PR. The PR triggers the plan pipeline — you see the diff before merging. Lightweight drift detection, zero extra tooling.
Wrapping community modules vs. using them directly
Community modules like terraform-aws-modules/eks/aws are excellent. They’re also 80+ variables with defaults shaped by the module maintainer’s conventions, not yours.
My default rule: wrap anything that touches shared infrastructure. A thin wrapper encodes your org’s standards — encryption settings, deletion protection, tagging conventions, approved instance types — and exposes a simplified interface (5–10 variables instead of 80). Upgrades happen in one place.
# tf-modules/eks/main.tf
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = var.cluster_name
cluster_version = var.cluster_version
# Org standards baked in — not exposed as variables
cluster_endpoint_private_access = true
cluster_endpoint_public_access = false
enable_cluster_creator_admin_permissions = true
}
The exception: genuinely generic utility modules (like the EKS IRSA role modules) where there’s no org policy to encode. Use them directly, pin the version, let Dependabot handle upgrades.
Coming up next
Part 3 covers the tf-live directory hierarchy in detail: how account.hcl / region.hcl / env.hcl assembles at runtime, the OIDC bootstrap that creates your cross-account roles, and the Terragrunt built-in functions that make navigation and state generation feel seamless.
Reference: hagzag/tf-modules · tf-live demo repo
Series Navigation
-
← Previous: part 1 — Why Declarative IaC and Where Terragrunt Fits
-
→ Next: Part 3 — Terragrunt Live: Structure, Dependencies, and Multi-Account
Discussion