Terraform

How to Structure a Terraform Project: 3 Levels from Starter to Enterprise

As a Terraform project grows from a personal script into enterprise infrastructure, its directory layout and state management must evolve with it. This guide walks through three levels of project structure — single state, monorepo with modules, and versioned module repositories — so teams can pick the right one for their scale.

October 5, 2026by Hiiragi Team
terraformiacinfrastructure-as-codehashicorpproject-structuredevops
How to Structure a Terraform Project: 3 Levels from Starter to Enterprise

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 first
  • variables.tf — values extracted into variables once the project needs to run in more than one place
  • providers.tf (later versions.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:

  1. Slow plan refreshes. With hundreds of resources in one state, every terraform plan refreshes the entire graph. Engineers actively testing infrastructure can wait several minutes per run, and the delay only grows.
  2. A single point of failure. Everything lives in one state file. A mistake with terraform state rm or terraform import can 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_id as an output and applies it to register that output in its state.
  • The subnet component reads that output through a terraform_remote_state data 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/ (or live/ or environments/) — the environment folders from Level 1, unchanged in spirit
  • modules/ — a folder per module, each holding a main.tf, variables.tf, and outputs.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.tf to versions.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

AspectLevel 1: Single StateLevel 2: MonorepoLevel 3: Module Repos
Module reuseNone — resources are defined directlyModules grouped in one repoEach module in its own repo
VersioningNoneCopy-paste folder versionsGit tags
Per-environment upgradesManual, all-at-once riskControlled via module copiesControlled via tag references
Plan speedSlows as resources growFast, split stateFast, split state
Setup overheadMinimalLowHigh (one repo per module)
Best forPersonal projects, early startupsSmall teams, single DevOpsLarge 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 plan frequently. 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 (or versions.tf), outputs.tf, and data.tf into 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


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.

Test your knowledge now

Our adaptive mock exams target exactly what you just read. Take a practice test and lock in the concepts.

Start Mock Exam