Git, CI/CD & GitOpsXXVII · Infrastructure Repository ArchitectureRepoArch
Terraform repository layout — modules, environments, and root modules
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
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 throughmoduleblocks. Amodules/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,modulereferences, 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.mddescribing purpose, environments, and apply policy. - A
.gitignoreexcluding.terraform/,*.tfstate,*.tfstate.backup,crash.log, and.terraform.tfstate.lock.info. - A
versions.tfpinning the Terraform CLI and provider versions so every module and environment sees the same constraint. - A
Makefilewith the standardinit,plan,apply,fmt,validateinvocations. - A
CODEOWNERSmapping 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
- 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. - Pin Terraform and provider versions at the repository
root. A drift between a module’s pin and the root pin
produces a confusing
terraform initerror. - Never commit state, lock files, or
.terraform/. The.gitignoreis the first file the new engineer should see. - Tag every reusable module. A module consumed outside the repository is a published artifact; the tag is the version.
- Consume cross-repo modules through
git submoduleor 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 addmechanics used at the cross-repo boundary.
Quiz
Knowledge check · 4 questions
Q1. A Terraform repository has the layout `modules/vpc/`, `modules/eks/`, `environments/prod/`, `environments/staging/`. Where should the `backend` block be declared?
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.
Q3. Name the three top-level directories in the HashiCorp-recommended Terraform layout, and state the role of each one.
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.