There is no correct Terraform repository layout, and any guide that opens by giving you one has skipped the part that matters.
What there is: a small number of forces that pull in different directions, and a layout that resolves them for your team’s size, ownership structure and risk tolerance. Get the forces right and the directory tree follows. Copy somebody else’s tree and you inherit the resolution of problems you may not have.
The short answer
Section titled “The short answer”Start with a single repository, directories per environment, and a modules/ directory for anything used more than once:
infrastructure/├── modules/│ ├── networking/│ ├── database/│ └── service/├── environments/│ ├── dev/│ ├── staging/│ └── production/└── .github/ └── workflows/Split when a specific force makes you — not preemptively. The rest of this lesson is about recognising which force is pushing, because the right split depends on which one it is.
The five forces
Section titled “The five forces”Every layout decision is one of these winning over the others.
Blast radius. How much can one apply affect? A root module covering an entire account means a mistake reaches everything in it.
Ownership. Who is accountable for a path, and can that be expressed? Ownership that does not map onto directory boundaries cannot be expressed in CODEOWNERS.
Coordination cost. How many people must agree to make a change? Fewer repositories means cheaper cross-cutting changes and more contention on the same files.
CI cost. How long does validation take, and how much of it was relevant? A repository where every pull request plans thirty root modules trains reviewers to skim.
State granularity. One state file per root module. Fewer, larger root modules mean bigger state files, slower plans, and more resources locked by a single operation.
Those five do not agree. Every layout in this lesson is a different answer to which one you privilege.
Root modules and reusable modules
Section titled “Root modules and reusable modules”The distinction the whole subject rests on.
A root module is a directory you run Terraform in. It has a backend configuration, a state file, and provider configuration. environments/production/ is a root module.
A reusable module is a directory that other configurations call. It has no backend and no state of its own; it contributes resources to the state of whatever root module calls it. modules/networking/ is a reusable module.
Two rules that prevent most structural confusion:
Reusable modules do not configure providers or backends. A module that declares provider "aws" { region = "eu-west-1" } cannot be reused in another region, and one that declares a backend cannot be reused at all. Modules declare required_providers in a terraform block; the root module configures them.
One root module, one state file, one apply. The boundary of a root module is the boundary of a Terraform operation. This is the single most consequential structural fact in Terraform, and most layout decisions are really decisions about where to draw it.
Layout 1: single repository, environment directories
Section titled “Layout 1: single repository, environment directories”The default, and correct for most teams.
infrastructure/├── modules/│ ├── networking/│ │ ├── main.tf│ │ ├── variables.tf│ │ ├── outputs.tf│ │ └── README.md│ └── service/├── environments/│ ├── dev/│ │ ├── main.tf│ │ ├── backend.tf│ │ ├── terraform.tfvars│ │ └── versions.tf│ ├── staging/│ └── production/├── policies/├── docs/└── .github/ ├── CODEOWNERS └── workflows/Each environment directory is a root module with its own backend and state. They call the same modules with different variables.
What this gets you. Environments that are genuinely separate — separate state, separate applies, an error in dev cannot touch production. Configuration reuse through modules rather than through copying. Every environment’s actual configuration is visible in one place, which is enormously useful when answering “why does production behave differently”.
What it costs. Repeated boilerplate in each environment directory. Changed-directory detection in CI, or you plan everything every time. A single repository’s permissions apply to production and dev alike — mitigated by CODEOWNERS and rulesets, but a permission model rather than a boundary.
The drift risk to watch. Environment directories diverge, one file at a time, until staging no longer predicts production. The counter is keeping the environment directories thin: a module call, variables, and nothing else. When production/main.tf contains resources that exist nowhere else, that is the signal.
Layout 2: separate repositories per environment
Section titled “Layout 2: separate repositories per environment”infra-dev/ infra-staging/ infra-production/├── main.tf ├── main.tf ├── main.tf├── backend.tf ├── backend.tf ├── backend.tf└── .github/ └── .github/ └── .github/
infra-modules/└── modules/What this gets you. A real permission boundary. Production write access is a separate grant, auditable at the repository level. Independent CI with no cross-environment noise. Different rulesets — production can require two approvals and a code owner while dev requires none.
What it costs. A change spanning environments is now several pull requests with an implicit ordering nobody enforces. Modules must be published and versioned, because a relative path no longer works. Drift becomes harder to see, because comparing environments means comparing repositories. And you now maintain three or four copies of the CI configuration.
When it is the right answer. When the permission boundary is a genuine requirement — regulatory separation, a different team operating production, a compliance regime that wants demonstrable access control. Not because it feels tidier.
Layout 3: repository per component
Section titled “Layout 3: repository per component”infra-networking/ infra-data/ infra-platform/Each component owns its own repository, with environments as directories inside.
What this gets you. Ownership that maps cleanly onto teams. Independent release cadence — the networking team ships without coordinating with the data team. Small blast radius per repository.
What it costs. Cross-component dependencies become cross-repository dependencies. Networking outputs that the data layer consumes must travel through remote state data sources or a published contract, and that coupling is now invisible in any single repository. A new engineer has no single place to read the estate.
When it is the right answer. When teams genuinely own components and change them independently. It scales well and it is over-adopted by small teams who then spend their time on cross-repository coordination they did not previously have.
Comparison
Section titled “Comparison”| Single repo, env dirs | Repo per environment | Repo per component | |
|---|---|---|---|
| Blast radius | Medium | Small | Small |
| Permission boundary | Paths, via rules | Repository | Repository |
| Cross-cutting change | One pull request | Several, ordered | Several, ordered |
| Module consumption | Relative paths | Versioned refs | Versioned refs |
| CI complexity | Change detection needed | Simple per repo | Simple per repo |
| Drift between envs | Visible | Hard to see | N/A |
| Onboarding | One place to read | Which repo? | Which repo? |
| Best for | Most teams | Hard permission separation | Team-owned components |
Inside a root module
Section titled “Inside a root module”Terraform loads every .tf file in a directory, so file names are for humans. Conventional, and worth following because it makes any repository navigable:
| File | Contains |
|---|---|
main.tf | Resources and module calls |
variables.tf | Input declarations |
outputs.tf | Output declarations |
versions.tf | required_version and required_providers |
backend.tf | Backend configuration |
providers.tf | Provider configuration |
terraform.tfvars | Values — non-secret only |
README.md | What this is and how to run it |
Two things about that table.
versions.tf is not optional. Pinning required_version and provider version constraints is what stops a provider upgrade changing your plan on a random Tuesday. .terraform.lock.hcl should be committed for the same reason.
terraform.tfvars must not contain secrets. It is committed, it is plaintext, and it is the single most common source of committed credentials in Terraform repositories. Secrets come from a secret manager, a data source, or the environment — never a committed file. If a value must exist locally, *.auto.tfvars in .gitignore and a terraform.tfvars.example with placeholders.
What else lives in the repository
Section titled “What else lives in the repository”infrastructure/├── modules/├── environments/├── policies/ # OPA/Rego, Sentinel, conftest├── tests/ # Terratest, terraform test├── docs/│ ├── runbooks/│ └── decisions/ # ADRs — why, not what├── scripts/└── .github/ ├── CODEOWNERS └── workflows/docs/decisions/ earns its place more than teams expect. Terraform records what the infrastructure is; nothing records why. An architecture decision record explaining why you chose a NAT gateway per availability zone survives the departure of the person who chose it.
policies/ is policy as code — the rules a plan must satisfy, versioned and reviewed like everything else. Keeping them in the repository they govern means a change to a rule and a change to the configuration it constrains can be reviewed together.
tests/ covers module behaviour. Terraform’s native terraform test handles a useful subset without external tooling, and the tests worth writing are the ones asserting a module’s contract rather than its implementation — that the module produces a resource with the expected name and exposes the expected outputs, not that it happens to use a particular argument internally.
What must not live in the repository
Section titled “What must not live in the repository”| Never commit | Why |
|---|---|
terraform.tfstate | Contains resource attributes, including secrets |
terraform.tfstate.backup | Same |
.terraform/ | Downloaded providers and modules — large, machine-specific |
*.tfvars with real values | Plaintext secrets |
crash.log | Can contain state fragments |
*.tfplan | Contains the same sensitive attributes as state |
The .gitignore that covers it:
**/.terraform/**.tfstate*.tfstate.*crash.logcrash.*.log*.tfvars*.tfvars.json!example.tfvarsoverride.tfoverride_*.tf*_override.tf*.tfplan.terraformrcterraform.rcNote the exception line: *.tfvars is ignored wholesale, then example.tfvars is re-included. That default-deny shape is deliberate — a new production-secrets.tfvars is ignored automatically rather than requiring somebody to remember. Do not ignore .terraform.lock.hcl; that one belongs in the repository.
Naming, and why it is load-bearing
Section titled “Naming, and why it is load-bearing”Directory names in an infrastructure repository are read under pressure. Somebody at 2am needs to know which directory corresponds to the thing that is broken, and ambiguity there costs real minutes.
Name environments after what they are, not what they were. prod, production, prd and live in the same organisation is a recipe for somebody applying to the wrong one. Pick one word and use it everywhere — directory names, workspace names, cloud account aliases, GitHub environment names, tags.
Name modules after what they provide, not the provider resource. modules/database/ survives a migration from RDS to Aurora; modules/aws-rds-instance/ does not, and renaming a module directory changes every source reference that points at it.
Avoid numeric ordering prefixes. 01-network/, 02-compute/ encodes a dependency order in the filesystem, which then becomes wrong the moment the order changes and misleading the moment somebody inserts 015-dns/. Dependencies belong in the configuration, expressed through outputs and data sources.
Make the production path obvious. Whatever the layout, it should be immediately clear which directories affect production. This is what makes CODEOWNERS rules readable, and reviewers rely on it more than they realise — a path containing production engages a different level of attention than one that does not.
Sharing values between root modules
Section titled “Sharing values between root modules”The problem every multi-root-module layout hits: the networking root module creates a VPC, and the application root module needs its ID. They have separate state, so the value has to cross a boundary.
Remote state data source. The consuming module reads the producing module’s state directly. It works, it is built in, and it has two real costs: the consumer needs read access to the producer’s state — which, since state contains sensitive attributes, is a broader grant than it appears — and it creates a hard coupling that no interface documents.
Provider data sources. Look the resource up by tag or name from the cloud provider instead of from state. Looser coupling, no state access needed, and it fails visibly if the resource is missing or ambiguous. Usually the better default.
Explicit inputs. Pass the value in as a variable, from a .tfvars file or a CI variable. Most explicit, most tedious, and the easiest to reason about — you can read the root module and know exactly what it depends on.
A published contract. For larger estates, the producing team publishes values to a parameter store or similar, and consumers read from there. This is the version that scales, because the interface is deliberate rather than incidental.
The failure to avoid is a lattice of remote state references between a dozen root modules, where nobody can say which module depends on which and every state file is readable by every pipeline. That configuration is fast to build and unpleasant to live with, and it is where the monorepo versus polyrepo discussion becomes concrete.
Evolving the layout
Section titled “Evolving the layout”Layouts should change. What matters is recognising the signal.
Split a root module when plans are slow enough that people avoid running them, when one apply locks resources several teams need, or when the blast radius of a mistake has become unacceptable.
Extract a module when the same block is copied a third time. Twice is a coincidence; three times is a pattern with a maintenance cost.
Extract a module to its own repository when two consumers genuinely need different versions at the same time. Not before — a module repository with three commits and one consumer is overhead.
Split the repository when a permission boundary is a requirement rather than a preference, or when teams are genuinely blocked on each other’s reviews.
The migration people regret is the opposite direction: consolidating twelve component repositories into a monorepo to “simplify”, and discovering that twelve independent CI configurations have become one that must handle every case.
Documentation that stays accurate
Section titled “Documentation that stays accurate”Terraform repositories rot documentation faster than application repositories, because the configuration is the documentation for what exists and there is nothing describing why.
A README per module, describing its interface. Inputs, outputs, an example call, and any non-obvious behaviour. Tooling can generate the input and output tables from the code, which means they stay correct; the prose around them is what a human writes.
A README at the repository root describing how to run things. Which directories are root modules, how to authenticate, what CI does automatically and what a human must do. This is what a new joiner reads first, and it is usually missing.
Decision records for choices that will be questioned. Why three availability zones rather than two. Why this module is duplicated rather than shared. Why production uses a different instance class. These are the questions that get re-litigated annually by people who do not know the original reasoning, and a dated one-page record ends the argument in thirty seconds.
No architecture diagrams that must be manually maintained. They are wrong within a quarter and nobody trusts them after that. If you want a diagram, generate it.
The test for whether documentation is working: can somebody who has never seen the repository make a small, safe change to dev without asking anybody a question? If not, the gap they hit is what to write down next.
Common mistakes
Section titled “Common mistakes”Copying a big company’s layout. It resolves coordination problems you do not have, at a cost you feel immediately.
Providers or backends in reusable modules. Makes the module unusable anywhere else.
Fat environment directories. Resources that exist only in production are the mechanism by which environments diverge.
Committing terraform.tfvars with real values. The commonest secret leak in Terraform repositories.
Not committing .terraform.lock.hcl. Provider versions then vary by machine and by day.
One enormous root module. Slow plans, huge blast radius, and a lock that blocks everyone.
Extracting modules to repositories too early. Version-management overhead with a single consumer.
No CODEOWNERS. In a single repository, paths are the only ownership boundary you have.
How the layout shapes CI
Section titled “How the layout shapes CI”The directory tree determines what your pipeline has to do, and teams often discover this only after the tree is set.
A single root module means CI is trivial: one plan, one apply, no detection logic. It also means every pull request plans everything, which is fine at ten resources and unbearable at a thousand.
Environment directories mean CI must decide which environments a change affects. A change under environments/dev/ plans dev. A change under modules/ plans everything that consumes it — and working out what consumes it is the part that requires either a dependency map you maintain or a decision to plan everything when shared code changes.
Separate repositories mean CI is simple again, per repository, at the cost of having several of them to keep consistent. Reusable workflows are the answer, and they become a shared dependency with its own versioning question.
The general principle: the pipeline’s complexity is inversely proportional to how much it plans. Planning everything is simple and slow; planning precisely what changed is fast and requires a system you now own. Most teams should start with the simple version and add detection when plan duration actually becomes a problem, rather than building the machinery first.
One practical consequence for layout: keeping root modules small enough that planning several of them is still fast buys you a much simpler pipeline than one enormous root module that forces you to optimise around it.
Mental model
Section titled “Mental model”A root module is a blast radius. Everything else in the layout is a decision about how large you want that to be, and who is allowed to change it.
Directory structure is downstream of that. Once you know how many independent applies you want and who owns each, the tree writes itself — and when the layout stops working, it is almost always because the blast radius or the ownership map changed, not because the directories were named wrongly.
What you learned
Section titled “What you learned”- Layout is a resolution of five competing forces: blast radius, ownership, coordination, CI cost and state granularity
- A root module has a backend and state; a reusable module has neither and must not configure providers
- One root module means one state file and one apply — this is the boundary everything else follows
- Single repository with environment directories is the right default; split when a specific force requires it
- Environment directories should stay thin, or they diverge
.terraform.lock.hclis committed; state, plans and real.tfvarsnever are- A
.gitignoreprevents future accidents but does not undo an exposure
Exercise
Section titled “Exercise”Use a disposable repository. No cloud credentials — the local and null providers are enough.
-
Build the single-repository layout with
modules/and three environment directories. Use alocal_fileresource so nothing real is created. -
Run
terraform initandplanin two environment directories. Confirm each has its own state. -
Add the
.gitignoreabove. Createenvironments/production/terraform.tfvarsand rungit status. Predict: is it ignored? -
Create
example.tfvars. Predict: is it ignored, and why does it differ? -
Add a provider block inside
modules/. Try to use that module from two environments with different settings. Predict: what breaks? -
Add
CODEOWNERSgiving a different owner toenvironments/production/. Open a pull request touching it and confirm the routing. -
Delete the repository.