Why Terraform Project Structure Matters
Most Terraform projects begin the same way: a single main.tf file in a single repository, defining a handful of resources for one environment. That layout is perfectly reasonable for a personal project or an early-stage startup. The problems only surface later — when the environment count grows, when the resource count reaches the hundreds, and when multiple engineers start working against the same state.
Structure is not an aesthetic concern. It directly affects how fast terraform plan runs, how risky a state file is to lose, and how a team ships changes to production without taking down every environment at once. Terraform does not enforce a particular layout, which means the decision falls to the people writing the code — and getting it wrong early becomes expensive to undo later.
This guide walks through the typical evolution of a Terraform project in three levels, explaining at each step what works, what breaks, and what to change.
Level 1: Single State with Environment Folders
The first version of a Terraform project is a single repository with a small number of files and one shared state.
The basic layout
A starter project typically holds everything in one folder:
main.tf— resources and the provider configuration, often mixed together at firstvariables.tf— values extracted into variables once the project needs to run in more than one placeproviders.tf(laterversions.tf) — the provider configuration plus version constraints, split out as a naming convention once the team grows
As soon as real customers are involved, serving traffic from the same environment used for development becomes untenable. The repository gains a folder per environment — dev, staging, qa, and one or more prod folders for different regions — plus a global folder for resources shared across environments, such as IAM users, S3 buckets, and container registries used to promote artifacts between stages.
What works well
Within a single state, Terraform's resource references are the strongest tool available. A subnet can reference aws_vpc.main.id directly, and Terraform builds an implicit dependency graph: it creates the VPC first, then the subnet once the VPC ID exists. This is simple, readable, and requires no extra wiring.
What breaks
Two problems emerge as the project grows:
- Slow plan refreshes. With hundreds of resources in one state, every
terraform planrefreshes the entire graph. Engineers actively testing infrastructure can wait several minutes per run, and the delay only grows. - A single point of failure. Everything lives in one state file. A mistake with
terraform state rmorterraform importcan corrupt it, and the blast radius is the entire project.
The component split
The fix at this level is to create subfolders for each component under each environment — a vpc folder, a subnet folder, an eks folder, an app folder, and so on. This splits the single state into multiple smaller states, so a plan against one component only refreshes that component's resources.
The trade-off is that resource references stop working across folders. The subnet component can no longer reach the VPC component's attributes directly. The standard solution is a terraform_remote_state data source, paired with an outputs.tf file in the source component:
- The VPC component exposes
vpc_idas an output and applies it to register that output in its state. - The subnet component reads that output through a
terraform_remote_statedata block and consumes it as a value.
Because the implicit dependency graph is gone, the components must be applied and destroyed in the correct order manually. Teams add a thin orchestration layer — a simple bash script that applies each component in sequence, CI/CD pipeline steps in the right order, or a tool like Terragrunt for larger setups.
Key insight: Splitting state is what keeps plan fast and reduces blast radius, but it replaces an automatic dependency graph with an explicit, human-maintained ordering. That ordering is easy to get wrong without an orchestration step.
Level 2: Monorepo with Modules
Once a project has stabilized, grouping related resources into reusable modules becomes the natural next step.
The layout
The repository gains two top-level folders:
envs/(orlive/orenvironments/) — the environment folders from Level 1, unchanged in spiritmodules/— a folder per module, each holding amain.tf,variables.tf, andoutputs.tf
A module is simply a group of Terraform files and resources placed in the same folder. The environment folders stop defining resources directly and instead invoke modules, passing values in as variables.
Module best practices
Two conventions matter at this level:
- Never initialize a provider inside a module. Providers are initialized in the environment (live) folders that call the module.
- Rename
providers.tftoversions.tf. This is a widely adopted community naming convention and makes the file's purpose — pinning Terraform and provider versions — immediately clear.
The same pattern applies to inter-component references. Rather than reading a remote state from inside the module, the module accepts the needed value as an input variable. The VPC module exposes vpc_id as an output, the live VPC folder re-exposes it in its own outputs.tf, and the subnet module receives it as a variable. Modules stay self-contained and testable.
The versioning problem
The monorepo is easy to manage for a small team or a single DevOps engineer, because modules and environments live in one place. It has one significant drawback: because a module is just a folder referenced by every environment, changing anything inside it triggers updates in all environments at once.
When a provider upgrade or a new feature needs to roll out gradually — first to dev, then to staging, then to each production region one at a time — that simultaneous blast radius is unacceptable. The intermediate solution is to version modules by copy-paste: duplicate the vpc module into a vpc-v2 folder, make the change there, then update each environment's module reference one by one and verify each apply.
Key insight: Copy-paste module versioning feels crude, but it is a legitimate intermediate step. It buys per-environment upgrade control without the overhead of separate repositories — and it is far easier to manage than most teams assume.
Level 3: Versioned Module Repositories
The most advanced structure moves each Terraform module into its own git repository, versioned with git tags.
The layout
The modules folder disappears from the main repository. Instead, each module lives in a dedicated repository following a naming convention such as terraform- — for example, terraform-aws-vpc. The live environment folders reference modules by git source and git tag rather than a local path:
module "vpc" {
source = "git::https://github.com/org/terraform-aws-vpc.git?ref=v0.1.0"
# ...
}
When a change is needed, the module is updated, committed, and released as a new tag (v0.1.1). Each environment upgrades by changing the tag reference, one environment at a time, with full control over sequencing.
The trade-off
This approach gives the strongest version control and per-environment upgrade discipline, but it scales poorly in a different direction: hundreds of modules mean hundreds of git repositories, and switching between them becomes cumbersome for a small team or a single person.
The practical guidance is to build reusable modules rather than one module per microservice. Many applications share the same scaffolding — an auto scaling group, a network load balancer, and related components — so a single flexible module can deploy multiple applications instead of spawning a repository per service.
The Three Levels Compared
| Aspect | Level 1: Single State | Level 2: Monorepo | Level 3: Module Repos |
|---|---|---|---|
| Module reuse | None — resources are defined directly | Modules grouped in one repo | Each module in its own repo |
| Versioning | None | Copy-paste folder versions | Git tags |
| Per-environment upgrades | Manual, all-at-once risk | Controlled via module copies | Controlled via tag references |
| Plan speed | Slows as resources grow | Fast, split state | Fast, split state |
| Setup overhead | Minimal | Low | High (one repo per module) |
| Best for | Personal projects, early startups | Small teams, single DevOps | Large teams, many environments |
Choosing the Right Level
The three levels are not a ladder that every project must climb. The right choice depends on team size and environment count:
- Stay at Level 1 while the project is small and the team is one or two people. The single-state simplicity is worth more than the premature overhead of modules and multiple states.
- Move to Level 2 when a small team needs reusable modules but wants to keep everything in one repository. The copy-paste versioning trade-off is acceptable here and keeps day-to-day management simple.
- Adopt Level 3 only when the team is large, environments are numerous, and per-environment upgrade control is a hard requirement. Build reusable modules to keep the repository count manageable.
Best Practices at Every Level
A few habits hold across all three levels:
- Run
terraform planfrequently. After any refactor, a plan should show no unexpected changes. Catching drift early is far cheaper than discovering it in production. - Prefer variables over hardcoding. Anything that differs between environments — regions, CIDR blocks, tags — should be a variable with a sensible default.
- Follow file naming conventions. Splitting
variables.tf,providers.tf(orversions.tf),outputs.tf, anddata.tfinto their own files makes a project legible to every engineer who touches it, not just the author. - Use remote state in production. The local-backend examples in this guide are for demonstration. A remote backend such as S3 with DynamoDB locking prevents concurrent applies and keeps the state durable.
- Expose only what is needed. Output variables are the contract between components. Exposing too much couples components; exposing too little forces hardcoding elsewhere.
Key insight: Structure exists to make two operations safe — changing infrastructure and understanding who changed what. Every layout decision should be evaluated against those two goals rather than against how the folder tree looks.
Related Reading
- How to Pass the Terraform Associate (004) Exam — the exam blueprint, domains, and a study plan for the credential that validates IaC fundamentals
- Terraform Associate 003 vs 004: What Actually Changed — what moved between the two exam versions
Put the Concepts Into Practice
A clean project structure is the foundation of every Terraform workflow, and the Terraform Associate exam tests exactly these fundamentals — modules, state, variables, and reusable configuration. Start a free mock exam on Hiiragi to assess your IaC knowledge and route practice time toward the gaps before exam day.
