Project Layout & Multi-Environment Patterns

How Terraform actually treats files and folders, the four ways to model dev/staging/prod and when each breaks down, and why splitting state along team boundaries is an organisational decision more than a technical one.

advanced 19 min lesson hands-on task included

Terraform does not care what your files are called. It cares enormously about which directory they are in, and that distinction drives every layout decision you will make.


Topic 1: Files Do Not Matter, Folders Do

Terraform reads every .tf file in the working directory and concatenates them into one logical configuration. The filenames are then discarded.

/project
├── main.tf       # these three
├── network.tf    # are identical in effect
└── database.tf   # to one big main.tf

Splitting main.tf into provider.tf and s3.tf changes nothing about the result. Ordering within a file means nothing either — Terraform builds a graph from references, not from position.

Subfolders are ignored by the root module. That is precisely what makes them available as modules. Put a .tf file in a subdirectory and Terraform will not read it unless something declares it as a module source.

Conventions worth following anyway, because humans read this:

/project
├── main.tf          # providers and module composition
├── variables.tf     # input declarations
├── outputs.tf       # output declarations
├── versions.tf      # required_version and required_providers
├── state.tf         # backend configuration (convention only)
└── terraform.tfvars # values

Beyond a certain size, split by system area — network.tf, dns.tf, compute.tf. The industry practice is roughly one file per resource grouping: it makes the code navigable and keeps diffs readable.


Topic 2: The Four Environment Patterns

PatternIsolatesBest forBreaks down when
WorkspacesState onlySame topology, different parametersEnvironments diverge architecturally
Directory per environmentState + configEnvironments that genuinely differDuplication across directories
Shared modules + thin env rootsState + inputs, shared logicMost production estatesRequires module discipline
Backend overridesStateLayering onto any of the aboveConfig-only, no logic separation

Directory per environment:

/environments
├── dev/
│   ├── main.tf
│   └── terraform.tfvars
├── staging/
└── prod/

Full separation. Easy to reason about, easy to wire into CI. The cost is duplication — a change to shared topology must be made three times, and eventually one copy is forgotten.

Module-driven — the usual production shape:

/modules
├── network/
├── compute/
└── database/
/envs
├── dev/        # each invokes shared modules with its own vars
├── staging/
└── prod/

Each environment root is thin: a backend block, provider config, and a few module calls with different inputs. Shared logic lives once in modules/. This is where most estates end up, and it is worth starting here rather than migrating later.

# envs/prod/main.tf
terraform {
  backend "s3" {
    bucket = "org-terraform-state"
    key    = "prod/network/terraform.tfstate"   # distinct key per environment
    region = "us-east-1"
  }
}

module "network" {
  source   = "../../modules/network"
  cidr     = "10.0.0.0/16"
  az_count = 3
}

Per-environment variable files:

terraform apply -var-file="prod.tfvars"
# prod.tfvars
environment   = "prod"
instance_type = "t3.large"
desired_count = 3

Topic 3: Splitting State

One enormous state file gives you slow plans and unlimited blast radius — a mistake in one team’s code can destroy another team’s resources, because they are in the same apply.

Split along team and lifecycle boundaries: the boundaries that decide who can break what, and what changes at the same cadence.

A common split:

platform/network/     # rarely changes, everyone depends on it
platform/shared/      # DNS, certificates, shared buckets
team-a/app/           # changes daily
team-b/app/

Downstream state reads upstream outputs via a data source rather than managing those resources:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "org-terraform-state"
    key    = "platform/network/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}

This decision is organisational as much as technical, and it is far cheaper to make early than to retrofit.


Topic 4: Protecting Production

The single most valuable guard rail in this whole topic: ensure production state cannot be overwritten by a dev run.

Layers that actually work:

  • Separate backend keys per environment — a dev run physically cannot write prod’s state file.
  • Separate credentials per environment. The dev pipeline’s role has no permissions in the prod account. This is the only guard that survives human error.
  • Separate pipelines, so an environment is selected by which job runs rather than by a flag someone types.
  • prevent_destroy on stateful production resources as a last-resort backstop.

Relying on operators remembering to select the right workspace or pass the right -var-file is not a control. It is a convention, and conventions fail at 3am.


Topic 5: Naming and Tagging

Embed the environment in resource names and tags so that a console view is unambiguous:

locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Owner       = var.owning_team
    StateKey    = "prod/network"
  }
}

The ManagedBy tag matters more than it looks — it tells the next person that hand-editing this resource will be reverted. The StateKey tag tells them which configuration to edit instead, which is the question they will actually have.


Try it yourself: Set up two environment directories sharing one module. Change the module and apply to dev only. Confirm prod is unchanged, then apply prod and watch the same change land. That is the whole value proposition of the module-driven layout in one exercise.

Common mistake: Branch-per-environment. Keeping dev, staging and prod as long-lived Git branches produces permanent merge conflicts and drift nobody can reason about, because the differences between environments are spread across a diff that never fully merges. Directory-per-environment makes those differences visible in one place.