Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXVII · Infrastructure Repository ArchitectureRepoArch

Terraform repository layout — modules, environments, and root modules

Advanced⏱ ~24 mingit

What you'll learn

  • Distinguish root modules, child modules, and reusable modules in a Terraform repository
  • Apply the HashiCorp-recommended layout (modules/, environments/, root module at the root)
  • State what belongs at the repository root versus inside a module directory
  • Recognise why each environment gets its own root module and its own state

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

Once a team has decided that Terraform is its own repository, the next decision is the layout inside it. HashiCorp’s recommended structure has three top-level pieces - modules/, environments/, and an optional root module at the repository root. The structure is what makes state, backends, and the consumption boundary predictable.

The three top-level directories

flowchart TB
    R["infra-terraform/ (root)"]
    R --> M["modules/"]
    R --> E["environments/"]
    M --> MA["modules/vpc/"]
    M --> MB["modules/eks/"]
    E --> EA["environments/prod/"]
    E --> EB["environments/staging/"]
    EA --> SA["State: prod backend"]
    EB --> SB["State: staging backend"]
  • modules/ holds reusable child modules - pieces of configuration meant to be consumed through module blocks. A modules/vpc/ module declares inputs, calls resources, exposes outputs. It has no backend and no provider.
  • environments/ holds per-environment root modules - one root per environment (prod, staging, dev). Each is a complete configuration: backend, provider, module references, and per-environment variable values. Each has its own state.
  • A root module at the repository root fits small codebases with one environment. When the team grows past three, this pattern is replaced by environments/<name>/.

What goes at the repository root

The repository root holds only what applies to every configuration in the repository:

  • A top-level README.md describing purpose, environments, and apply policy.
  • A .gitignore excluding .terraform/, *.tfstate, *.tfstate.backup, crash.log, and .terraform.tfstate.lock.info.
  • A versions.tf pinning the Terraform CLI and provider versions so every module and environment sees the same constraint.
  • A Makefile with the standard init, plan, apply, fmt, validate invocations.
  • A CODEOWNERS mapping directories to teams.

What does not go at the root: a provider.tf (per environment so each can use a different alias), a backend.tf (per environment), or any *.tf that creates resources for a single environment.

What goes inside a module and an environment

A modules/<name>/ directory is a child module if and only if it is meant to be called from another module’s module block:

modules/vpc/
  main.tf
  variables.tf
  outputs.tf
  versions.tf
  README.md

A child module has no backend block and no provider block of its own; the provider is passed in from the calling root. The calling root owns the state for every resource the child creates.

An environments/<name>/ directory is a root module: a complete configuration that declares a backend block (one per environment, so prod state and staging state never share a key prefix), a provider block, module references, and a terraform.tfvars per environment. cd environments/prod && terraform init && terraform plan is a complete workflow, and the state file is the one for prod, not for dev.

Cross-repo consumption

When a child module is consumed by an environment in another repository, the module becomes a submodule:

git submodule add "$MODULE_REPO_URL" modules/vpc
git submodule update --init --recursive

The submodule is pinned to a specific commit; the consuming repository’s main records the pin. The consuming repository bumps the pin in its own PR.

Production discipline

  1. Use the modules/ + environments/ + optional root layout. A flat layout cannot have multiple backends, and the moment there is a second environment the flat layout forces a directory switch anyway.
  2. Pin Terraform and provider versions at the repository root. A drift between a module’s pin and the root pin produces a confusing terraform init error.
  3. Never commit state, lock files, or .terraform/. The .gitignore is the first file the new engineer should see.
  4. Tag every reusable module. A module consumed outside the repository is a published artifact; the tag is the version.
  5. Consume cross-repo modules through git submodule or versioned sources, not by copying the files. A copy loses the upstream history and the upstream signing.

Cross-course references

  • Terraform for Production Sysadmins - Part IX (State) covers the per-environment state files this layout enables.
  • Terraform for Production Sysadmins - Part XII (RegistryModules) covers versioned consumption for cross-repo modules.
  • Git Internals for Production Engineers - Part XXXI (Submodules) covers the git submodule add mechanics used at the cross-repo boundary.

Quiz

Knowledge check · 4 questions

  1. Q1. A Terraform repository has the layout `modules/vpc/`, `modules/eks/`, `environments/prod/`, `environments/staging/`. Where should the `backend` block be declared?

  2. Q2. A Terraform child module under `modules/vpc/` is allowed to declare its own `backend` block, as long as the block is empty by default and only filled in by the calling root.

  3. Q3. Name the three top-level directories in the HashiCorp-recommended Terraform layout, and state the role of each one.

  4. Q4. Recommend the right Terraform layout for a team that has three environments (prod, staging, dev) and one reusable VPC module, and explain the command sequence that adds the VPC module as a submodule of a Kubernetes cluster repository in a different repo.

    A platform team maintains a Terraform repository with a `modules/vpc/` child module. The same VPC is consumed by an internal Kubernetes cluster repository that needs the VPC's ID, subnet IDs, and route table IDs. The team wants the Terraform repository to own the lifecycle of the VPC module and the Kubernetes repository to consume a pinned version.

Passing score: 75%. Answers are checked in this browser.