Terraform Project Structure for Multiple Environments (2026 Guide)
Every Terraform codebase I have inherited had the same argument fossilised in it: one engineer wanted workspaces, another wanted a folder per environment, and the repo ended up with both plus a prod-old directory nobody would delete. The question of Terraform project structure for multiple environments sounds like taste, but it decides how much of production a typo can reach, who can read which state file, and how long it takes to promote a change from dev to prod. This post compares the four structures people actually use in 2026 (CLI workspaces, a directory per environment, Terragrunt and Terraform Stacks), shows what changed with Terraform 1.15's constant variables and OpenTofu's early evaluation, and ends with what I would pick at each team size.
What does a Terraform project structure actually have to solve?
Strip away the folder names and every layout answers four questions.
- State isolation. Is prod's state a different object, in a different place, behind different credentials, from dev's? If one
terraform applywith the wrong selection can touch both, they are not isolated. - Blast radius. How many resources does one root module own? A single root with 3,000 resources means a slow plan, a long lock, and a refresh that touches everything when you only wanted to change a security group.
- Promotion. How does a change move from dev to prod? Ideally the same module version, with different inputs, applied in order.
- Drift between environments. How do you stop staging slowly becoming a different shape from prod because someone hand-edited a copy?
Everything below is a trade-off between those four, plus how much tooling you are willing to run. Versions referenced are current as of today: Terraform 1.17.1 (28 October 2026), OpenTofu 1.13.9 (5 November 2026) and Terragrunt 1.2.1 (23 October 2026).
Are Terraform workspaces good for environments?
CLI workspaces give you several state files for one configuration in one working directory. terraform workspace select prod and the same code now points at a different state. It is the smallest possible amount of structure, which is why people reach for it.
HashiCorp's own documentation is blunt about the limit. The workspaces page says workspaces "are not appropriate for system decomposition or deployments requiring separate credentials and access controls." The CLI use-case page explains why: CLI workspaces in a working directory share the same backend, so they are not a suitable isolation mechanism when each deployment needs its own credentials.
In practice that means:
- The credentials that can write dev state can also write prod state, because it is the same bucket and usually the same key prefix.
- The code is identical by construction, which sounds good until prod needs a resource dev does not. Then you get
count = terraform.workspace == "prod" ? 1 : 0sprinkled through the modules, and the configuration becomes a set of environment conditionals. - The currently selected workspace is invisible state on someone's laptop. "Which workspace am I in?" is a question you should not need to ask before
apply.
Workspaces are fine for what the docs describe: short-lived parallel copies of one stack, such as a feature-branch environment that gets destroyed on merge. I would not use them as the boundary between staging and production. Note that HCP Terraform "workspaces" are a different thing entirely: each one has its own state, variables and permissions, so they do isolate. The name collision causes a remarkable number of confused design reviews.
Directory per environment: the boring layout that still wins
The most common structure in 2026 is still a directory per environment (and often per component), each a small root module that calls versioned shared modules:
infra/
├── modules/ # or a separate repo / private registry
│ ├── network/
│ └── service/
└── live/
├── dev/
│ ├── network/ # root module: backend.tf, main.tf, terraform.tfvars
│ └── payments-service/
├── staging/
│ ├── network/
│ └── payments-service/
└── prod/
├── network/
└── payments-service/
Each leaf directory is its own root module with its own state key, and ideally its own role. A prod root looks like this:
# live/prod/payments-service/main.tf (Terraform 1.17.x or OpenTofu 1.13.x)
terraform {
required_version = ">= 1.11.0"
backend "s3" {
bucket = "acme-tfstate-prod" # separate bucket (and account) per environment
key = "payments-service/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true # S3-native locking, GA in Terraform 1.11; DynamoDB args deprecated
encrypt = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
module "service" {
# Pin to a tag. Dev can run v2.4.0 while prod stays on v2.3.1.
source = "git::https://github.com/acme/terraform-modules.git//service?ref=v2.3.1"
name = "payments"
environment = "prod"
min_tasks = 6
}
The use_lockfile argument is the S3-native locking mechanism that Terraform 1.11 made generally available, at which point it deprecated the DynamoDB arguments. If you want the detail on backends and locking, I covered it in Terraform State in 2026.
What this layout gets right: state isolation is physical (different bucket, different IAM role), blast radius is one component in one environment, and promotion is a one-line diff that bumps ref=v2.3.1 to ref=v2.4.0 in the prod directory after it has lived in dev for a week. Code review shows exactly what changes where.
What it gets wrong: duplication. Each leaf repeats the backend block, the provider block and the module call. With three environments and fifteen components that is 45 nearly identical main.tf files, and the backend key is the one line people forget to change when they copy a directory. That copy-paste mistake is the classic way two roots end up writing the same state.
The answer to the duplication is to keep the leaves thin. All logic lives in modules; a root is a backend, a provider and one or two module calls with inputs. If you find a resource block in live/, it probably belongs in a module. The sibling guide, How to Write Reusable Terraform Modules, covers how to build those modules so that thin roots are possible.
What changed in 2026: module sources can use variables now
For years the most-requested Terraform feature was "let me put a variable in source, version or the backend block." Terraform refused because those are resolved during init, before normal evaluation.
OpenTofu shipped the answer first. OpenTofu 1.8 (released 29 July 2024) added early evaluation: variables and locals can be used in backend configuration, module sources and versions, and encryption configuration, as long as they do not depend on resources, data sources or module outputs.
Terraform followed in 2026. Terraform 1.15.0 (29 April 2026) added support for "variables and locals in module source and version attributes", gated by a new const argument on variables: per the module block reference, any variable referenced in source or version must declare const = true. Terraform 1.17.0 (21 October 2026) extended the same idea to provider requirements. As far as I can see in the changelogs, Terraform still does not accept variables in the backend block, which OpenTofu does.
That matters for structure. With early evaluation, one root module can serve all environments with the backend and module version coming from a per-environment .tfvars file:
# OpenTofu 1.8+ only: variables in backend and module version
variable "env" {
type = string
}
variable "service_module_version" {
type = string
}
terraform {
backend "s3" {
bucket = "acme-tfstate-${var.env}"
key = "payments-service/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
}
}
module "service" {
source = "app.terraform.io/acme/service/aws" # private registry module
version = var.service_module_version
environment = var.env
}
Run with tofu init -var-file=prod.tfvars. This is a genuinely useful halfway house between workspaces and directories: one copy of the code, but separate buckets per environment, so the credentials boundary survives. The cost is that the shape of every environment is now identical again, and the environment you are touching is decided by a command-line flag rather than a directory you can see in the diff. I would only do this with a CI pipeline that sets the var file, never from laptops.
Terragrunt in 2026: v1, stacks and units
Terragrunt has spent a decade as the thing teams adopt when directory-per-environment duplication starts to hurt. It reached v1.0.0 on 30 March 2026, with a promise of no breaking changes in minor releases, and is at 1.2.1 today. It is MIT-licensed and works with both OpenTofu and Terraform.
The core idea is the same as the directory layout, with the boilerplate generated. A root.hcl defines the remote state once and each unit inherits it, with the state key derived from the directory path, so the copy-paste key bug goes away. Dependencies between units (dependency "vpc") are explicit, and terragrunt run --all applies them in order.
The newer feature is explicit stacks. A terragrunt.stack.hcl file declares units by source, path and values, and terragrunt stack generate writes them into a .terragrunt-stack directory that the docs suggest you gitignore and regenerate on demand. One stack file per environment, pinned to a catalog ?ref=, replaces a tree of near-identical directories.
The 1.0 release also removed the tflint that used to be compiled into the binary, because TFLint moved to a BUSL licence; if your hooks call it you now install TFLint yourself (it is at v0.66.0).
The cost is real: another binary, another configuration language layered over HCL, and a debugging experience where the error is sometimes in the generated code in .terragrunt-cache rather than the file you edited. For a platform team owning 200 roots, that is worth paying. For a team of three with 12 roots, it usually is not.
Terraform Stacks: HashiCorp's answer, and its limits
Terraform Stacks are HashiCorp's native version of the same idea. You write components (each a module) in .tfcomponent.hcl files and declare deployments, typically one per environment or region, in a .tfdeploy.hcl file. HCP Terraform then plans and applies each deployment, with orchestration rules for promotion. HashiCorp announced general availability at HashiConf 2025, and Terraform 1.13 added a terraform stacks command to the main CLI.
The constraints are what decide whether it fits:
- It is an HCP Terraform feature. The docs describe it as part of HCP Terraform and say it is not available on legacy team plans. There is no OpenTofu equivalent, and no way to run it against a plain S3 backend.
- Limits per Stack: 20 deployments, 100 components and 10,000 resources, per the same page.
- Stack resources count toward billing. HCP Terraform is priced per managed resource: $0.10, $0.47 or $0.99 per resource per month on Essentials, Standard and Premium.
If you are already on HCP Terraform and paying per resource, Stacks remove most of the reasons to adopt Terragrunt. If you are not, they are a reason to move platforms, not a structure you can adopt on its own.
Terraform workspaces vs directories vs Terragrunt vs Stacks
| Approach | State isolation | Duplication | Works with OpenTofu | Extra cost | Source |
|---|---|---|---|---|---|
| CLI workspaces | Same backend and credentials | None | Yes | None | Terraform workspaces docs |
| Directory per environment | Separate backend, role per env | High (thin roots help) | Yes | None | Module block reference |
| OpenTofu early evaluation | Separate bucket via var file | Low | OpenTofu 1.8+ only | None | OpenTofu 1.8 notes |
| Terragrunt 1.2 | Separate, key from path | Low | Yes | A second tool | Terragrunt 1.0 release |
| Terraform Stacks | Per deployment in HCP | Low | No | $0.10 to $0.99 per resource per month | Stacks docs, pricing |
How should you version modules across environments?
Whichever structure you pick, the thing that makes promotion safe is that dev and prod call the same module at different, explicit versions. Three rules I hold to:
- Never point a prod root at a branch.
?ref=mainmeans prod changes when someone merges, not when you promote. Use tags (?ref=v2.3.1) or registry versions with an exact or~>constraint. The public and private registries require semantic version tags, with an optional leadingv. - Promote by diff. The PR that moves prod from v2.3.1 to v2.4.0 should touch one line in the prod root. If it touches the module too, you are testing in production.
- Test the module, not the environment.
terraform testwith mock providers (since Terraform 1.7) lets you check a module's logic without credentials. I covered the wider toolchain in How to Test Terraform.
Size your roots by change rate and ownership. Networking changes rarely and breaks everything when it does, so it gets its own root. A service's queues and IAM change weekly and belong to that team. If two teams need to touch one root, it is probably two roots.
FAQ
Should I use Terraform workspaces for dev, staging and prod?
Not for environments that need different credentials. HashiCorp's docs say CLI workspaces share one backend and are not a suitable isolation mechanism for that case. Use them for short-lived copies, such as per-branch test environments, and use separate directories or separate HCP Terraform workspaces for real environments.
Is Terragrunt still needed in 2026?
It is less needed than it was. Terraform 1.15 added constant variables in module sources, OpenTofu has had variables in backends since 1.8, and Terraform Stacks cover orchestration on HCP Terraform. Terragrunt is still the most complete option for OpenTofu users or self-managed state with many roots and dependencies between them.
What is the difference between Terraform Stacks and Terragrunt stacks?
Terraform Stacks are an HCP Terraform feature using .tfcomponent.hcl and .tfdeploy.hcl files, executed by HCP. Terragrunt stacks are terragrunt.stack.hcl files that generate units locally and run with any backend and either engine. They solve the same problem with different lock-in.
How big should a Terraform root module be?
Small enough that a plan finishes quickly and one team owns it. Split by lifecycle and ownership: network, shared data stores, and each service separately. There is no official resource limit for a root, but Stacks cap a whole Stack at 10,000 resources, which gives a sense of where HashiCorp expects things to get unwieldy.
If I were setting up a new Terraform repo this quarter, I would start with a directory per environment, thin roots, S3 state with use_lockfile in a separate account per environment, and modules pinned by tag, because it needs no tool beyond the engine and every change is visible in a diff. I would move to Terragrunt once the number of roots passed thirty or so, or to Stacks only if the organisation was already committed to HCP Terraform. Before any of that, I would make the modules good, which is what the reusable modules guide is about.
Related: Terraform State in 2026: S3 Locking Replaced DynamoDB, What Else Changed, Infrastructure as Code in 2026 and Crossplane vs Terraform vs Pulumi in 2026.
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
Terraform vs Pulumi vs Crossplane in 2026: Which to Choose
Terraform, OpenTofu, Pulumi and Crossplane compared as of October 2026: how each handles state and drift, what it costs, and which team fits which tool.
12 minOct 5, 2026Infrastructure as Code in 2026: Two Forks at 1.16 and 1.13, a $6.4B Owner, 912 Public State Files, and One Agent That Ran terraform destroy
Infrastructure as code three years after the BSL relicence: Terraform 1.16.5 under IBM versus OpenTofu 1.13.1 under the Linux Foundation and who shipped what first, HCP Terraform at $0.10 to $0.99 per resource with the legacy free plan gone, CDKTF and System Initiative archived, Pulumi 3.267 and Crossplane 2.4, a Terraform MCP server that grew from registry lookups to workspace administration in 15 months, 44 percent running AI for infrastructure but 34 percent trusting it, 912 exposed state files with 41 live AWS keys, and the agent that ran terraform destroy on 2.5 years of production.
13 minNov 10, 2026Is Ansible Still Relevant in 2026? ansible-core 2.22 vs Terraform
Is Ansible still relevant in 2026? What ansible-core 2.22 and Ansible 14 changed, how Ansible compares with Terraform, cloud-init and Kubernetes, where configuration management still wins, and what Event-Driven Ansible and Lightspeed actually do.
11 min