The organizing principle
One state file = one blast radius, one lock, one pipeline. Project structure is not
aesthetics — it is deciding how to partition state. Split along two axes:
environment (dev / stage / prod) and component (network / data / app —
grouped by rate of change and ownership).
The canonical layout (directory per environment)
infrastructure/
├── modules/ # shared building blocks (or a separate versioned repo)
│ ├── vpc/
│ └── service/
└── envs/
├── dev/
│ ├── network/ # one root module = one state file = one key
│ │ ├── main.tf # backend key: dev/network/terraform.tfstate
│ │ └── …
│ └── app/
├── stage/
│ └── …
└── prod/
├── network/
└── app/
- Each leaf directory is a thin root module: backend block + provider config + a few module calls + env-specific variable values. Logic lives in modules, not in env dirs.
- Anti-pattern: copy-pasting resource blocks between env dirs. Envs should differ in inputs, not implementation — otherwise envs drift apart silently.
- Bigger orgs: modules in their own repo, versioned — prod pins
v2.3 while dev tries v3.0 (Gruntwork pattern).
Sources: HashiCorp: structuring for production ·
AWS Prescriptive Guidance ·
Gruntwork infrastructure-live
Workspaces vs directories
| CLI workspaces | Directory per env |
| Code | One copy, zero duplication | Thin roots per env (some boilerplate) |
| State | Separate state files, same backend | Fully separate state, backend, even account |
| Credentials | Shared — dev access ≈ prod access | Per-env roles/accounts possible |
| Env differences | Conditionals on terraform.workspace (grows ugly) | Different inputs / module versions per env |
| Wrong-env risk | One forgotten workspace select away | You're in the directory you're in |
HashiCorp's own guidance: workspaces suit environments that barely deviate from each
other; use directories when configuration differs, when different people manage different
environments, or when prod needs its own credentials and access control. (Note: HCP Terraform
"workspaces" are a different, heavier concept than CLI workspaces — don't conflate.)
Source: CLI workspaces docs
How separate states talk
# in envs/prod/app — read the network stack's outputs:
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
}
}
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
- Only root-level outputs of the other state are visible — module encapsulation again.
- Reads take no lock — pipelines on different states run concurrently.
- Looser-coupled alternative: plain data sources (look the VPC up by tag). Survives the other stack's refactors; costs an API query and a naming convention.
- Reading another state requires read access to its whole state file (secrets included) — for cross-team boundaries, prefer data-source lookups or explicitly shared parameters (e.g. SSM).
Decision checklist
- ☑️ Envs differ in resources, people, or credentials → directories, not workspaces
- ☑️ Plan slow / lock contention / scary blast radius → split state by component
- ☑️ Logic appearing in env dirs → push it down into a module
- ☑️ Prod and dev on different module versions → that's healthy, that's the point
- ☑️ Cross-state references →
terraform_remote_state for tight coupling, data sources for loose