Skip to content

Terraform Repository Structure: Complete Guide

Lesson 2 of 10Intermediate14 min readGit for DevOps & Infrastructure · Infrastructure as CodeVerified: Terraform CLI and module documentation, September 2026

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.

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.

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.

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.

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.

Single repo, env dirsRepo per environmentRepo per component
Blast radiusMediumSmallSmall
Permission boundaryPaths, via rulesRepositoryRepository
Cross-cutting changeOne pull requestSeveral, orderedSeveral, ordered
Module consumptionRelative pathsVersioned refsVersioned refs
CI complexityChange detection neededSimple per repoSimple per repo
Drift between envsVisibleHard to seeN/A
OnboardingOne place to readWhich repo?Which repo?
Best forMost teamsHard permission separationTeam-owned components

Terraform loads every .tf file in a directory, so file names are for humans. Conventional, and worth following because it makes any repository navigable:

FileContains
main.tfResources and module calls
variables.tfInput declarations
outputs.tfOutput declarations
versions.tfrequired_version and required_providers
backend.tfBackend configuration
providers.tfProvider configuration
terraform.tfvarsValues — non-secret only
README.mdWhat 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.

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.

Never commitWhy
terraform.tfstateContains resource attributes, including secrets
terraform.tfstate.backupSame
.terraform/Downloaded providers and modules — large, machine-specific
*.tfvars with real valuesPlaintext secrets
crash.logCan contain state fragments
*.tfplanContains the same sensitive attributes as state

The .gitignore that covers it:

**/.terraform/*
*.tfstate
*.tfstate.*
crash.log
crash.*.log
*.tfvars
*.tfvars.json
!example.tfvars
override.tf
override_*.tf
*_override.tf
*.tfplan
.terraformrc
terraform.rc

Note 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.

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.

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.

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.

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.

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.

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.

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.

  • 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.hcl is committed; state, plans and real .tfvars never are
  • A .gitignore prevents future accidents but does not undo an exposure

Use a disposable repository. No cloud credentials — the local and null providers are enough.

  1. Build the single-repository layout with modules/ and three environment directories. Use a local_file resource so nothing real is created.

  2. Run terraform init and plan in two environment directories. Confirm each has its own state.

  3. Add the .gitignore above. Create environments/production/terraform.tfvars and run git status. Predict: is it ignored?

  4. Create example.tfvars. Predict: is it ignored, and why does it differ?

  5. Add a provider block inside modules/. Try to use that module from two environments with different settings. Predict: what breaks?

  6. Add CODEOWNERS giving a different owner to environments/production/. Open a pull request touching it and confirm the routing.

  7. Delete the repository.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The infrastructure-as-code repository template and deployment PR checklist are in the Professional Toolkit.