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 validate fails 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

tfsec was 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; tfsec still 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