Structuring a Terraform repository
A Terraform repository that works for one person and one environment becomes a tangle when it has to serve several environments and several engineers. How you structure the repository — where modules live, how environments are laid out, how state is split — determines whether the codebase stays maintainable or turns into copy-pasted chaos that drifts and breaks. This lesson is structuring a Terraform repository at scale, building on the Foundation's modules lesson and this module's state lesson.
The goals a good structure serves
Before a layout, be clear what you are optimising for — the structure exists to give you:
- No duplication — the same infrastructure (a VPC, a service) defined once and reused, not copy-pasted per environment (the DRY principle; the Foundation's modules lesson).
- Isolated state and blast radius — each environment (and often each component) has its own state (the state lesson), so a change or mistake affects only that slice, and prod cannot be touched while changing dev.
- Environments that differ only where intended — staging and production share the same structure and differ only in inputs (sizes, counts), so they cannot drift in shape.
- Reviewable changes — a change to one environment or component is a focused, understandable pull request.
Every structural choice below serves these goals. Get them and the repo scales to many environments and engineers; miss them and you get drift, duplication and dangerous coupling.
The core split: modules and environments
The foundational structure separates reusable modules from per-environment configuration:
terraform/
modules/ # reusable building blocks — NEVER applied directly
vpc/ # a reusable VPC (the networking module's design)
main.tf variables.tf outputs.tf
eks-cluster/
service/
environments/ # one directory per environment — each has its OWN state
dev/
main.tf # calls modules with dev inputs; its own backend/state
backend.tf # state key: dev/...
terraform.tfvars # dev-specific values
staging/
production/
modules/holds the how — reusable definitions of your infrastructure pieces (a VPC module, an EKS-cluster module, a service module), written once, with input variables. These are never run directly; they are called.environments/holds the what for each environment — each environment directory is a thin configuration that calls the modules with environment-specific inputs, and has its own backend/state (the state lesson). An environment'smain.tfis mostlymoduleblocks:
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16" # dev
azs = ["eu-west-1a", "eu-west-1b"]
}
module "cluster" {
source = "../../modules/eks-cluster"
cluster_name = "dev"
node_count = 2 # dev: small; production would pass more
vpc_id = module.vpc.id
}
Staging and production have the same main.tf shape, passing bigger numbers. So the infrastructure is defined
once in modules; the environments only supply inputs — which is exactly the "environments differ only in
inputs" goal, and it means you cannot accidentally give production a different structure from staging, only
different sizes.
Isolating state per environment (and component)
Combine this with the state lesson: each environment directory has its own remote state (its own key in
the S3 backend). So:
- Running
terraform applyinenvironments/dev/touches only dev's state and dev's resources — production is untouchable from there. - Production has its own state, changed only by running Terraform in
environments/production/(usually only via CI, with approval).
For larger systems you split further, per component: separate state for networking, the cluster, and the applications within an environment — so a change to the app does not lock or risk the networking. This is the blast-radius isolation the state lesson argued for, realised in the directory layout: state boundaries follow directory boundaries.
Keeping modules good
Because modules are the reusable heart of the repo, their quality matters (the code-quality lessons applied to infrastructure):
- One module, one clear responsibility — a
vpcmodule, aneks-clustermodule — with documented input variables and outputs. A giant do-everything module is as bad as a giant function. - Pin module and provider versions — so an upstream change does not silently alter your infrastructure (the reproducibility discipline throughout this course). Reference registry modules by exact version too.
- Use well-tested registry modules where they exist — for standard AWS infrastructure (a VPC, an EKS cluster), the community/official Terraform modules are battle-tested; calling one is usually wiser than hand-writing dozens of resources.
- Compose small modules — a service module might call the VPC's outputs; environments wire modules together through their inputs and outputs.
Don't over-structure too early
A caution consistent with the whole course: introduce this structure when you have the need, not before. For a single environment and a few resources, a flat configuration is fine; the modules/environments split earns its complexity when you have a second environment (real duplication) or multiple engineers (real coupling risk). Building an elaborate module hierarchy for one small environment is premature abstraction — a cost with no payoff yet. Grow into the structure as environments and the team grow.
The picture to hold: modules/ for reusable pieces, environments/{dev,staging,production}/ each calling
them with different inputs and owning its own state, split further per component as you grow, with well-scoped
versioned modules. That structure gives no duplication, isolated blast radius, environments that differ only
in inputs, and reviewable changes — which is what lets Terraform scale to a real AWS estate with many engineers.
Check your work
Goals: no duplication (define once, reuse), isolated state/blast radius (per-environment/component state), environments differing only in inputs (same shape, different sizes), reviewable focused changes.
Core split: modules/ = reusable how (vpc, eks-cluster, service — with input variables; never applied
directly, only called); environments/{dev,staging,production}/ = thin what-for-here (call modules with
env inputs; own backend/state). Env main.tf is mostly module blocks; all envs share the shape, differ in
inputs.
State isolation: each environment directory has its own remote state (own key) → apply in dev/
touches only dev; production changed only from production/ (via CI + approval). Split further per component
(networking/cluster/app) for finer blast radius — state boundaries follow directory boundaries.
Good modules: one responsibility + documented I/O; pin versions; prefer well-tested registry modules; compose small modules via inputs/outputs.
Don't over-structure early: flat config is fine for one small environment; adopt the split when you have a second environment or multiple engineers (real duplication/coupling). Premature abstraction is a cost.
Practice
- State the four goals a good Terraform repo structure serves, and why each matters at scale.
- Sketch the modules/environments directory layout and explain what lives where.
- Write an environment
main.tfthat calls a VPC and cluster module with environment-specific inputs. - Explain how per-environment (and per-component) state isolates blast radius, tying to the directory layout.
- List three practices that keep modules maintainable and explain each.
- Explain when you would introduce this structure and why not before then.
Official documentation
- Terraform — Module creation & standard structure — Structuring modules and repositories.
- Terraform — Modules — Reusable, composable infrastructure.
- Terraform Registry — Well-tested modules for AWS infrastructure.
Next: writing reusable modules.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship