Directory-per-Environment Pattern
The idea in one sentence
Section titled “The idea in one sentence”Directory-per-environment gives every environment a genuinely separate directory with its own backend configuration, while every environment’s root config calls the same shared module with different input values.
A real directory, a real backend, per environment
Section titled “A real directory, a real backend, per environment”Instead of one workspace-selected state file, this pattern uses one real directory per environment, each with its own root-level .tf files:
environments/├── dev/│ └── main.tf├── staging/│ └── main.tf└── prod/ └── main.tfmodules/└── app/ └── main.tfThe key difference from copy-pasting a whole environment is what lives inside each main.tf: not a full copy of the application’s resources, but a small root configuration with its own backend block and a single module call into one shared module:
terraform { backend "s3" { bucket = "acme-terraform-state-dev" key = "app/terraform.tfstate" region = "us-east-1" use_lockfile = true }}
provider "aws" { region = "us-east-1" profile = "acme-dev"}
module "app" { source = "../../modules/app"
environment = "dev" instance_type = "t3.micro" instance_count = 1}terraform { backend "s3" { bucket = "acme-terraform-state-prod" key = "app/terraform.tfstate" region = "us-east-1" use_lockfile = true }}
provider "aws" { region = "us-east-1" profile = "acme-prod"}
module "app" { source = "../../modules/app"
environment = "prod" instance_type = "m5.large" instance_count = 3}Notice each backend points at a completely separate S3 bucket, tied to a separate profile (and, in a real setup, often a genuinely separate AWS account) — dev and prod cannot accidentally share state, because there is no shared state to accidentally touch. The actual application resources live once, in modules/app, and every environment simply calls that module with different instance_type and instance_count values.
What this genuinely solves
Section titled “What this genuinely solves”This directly fixes workspaces’ weak isolation. Each environment directory can point at its own AWS account, its own region, and its own backend bucket, with nothing tying them together at the Terraform level. There is no shared state file for a mistyped workspace name to accidentally touch, and no shared provider block for the wrong credentials to sneak through. If dev and prod genuinely need to live in different AWS accounts for blast-radius isolation, this pattern can express that; workspaces cannot.
The DRY problem this reintroduces
Section titled “The DRY problem this reintroduces”Look again at those two main.tf files. The instance_type, instance_count, and environment inputs are the only lines that meaningfully differ between them — three lines out of roughly fifteen. Everything else — the backend "s3" block’s shape, the provider "aws" block, and the module "app" block’s source argument — is boilerplate, repeated nearly verbatim in every environment directory. Bump the module to a new version, add a new required provider argument, or rename the state key convention, and that change has to be hand-applied to dev/main.tf, staging/main.tf, and prod/main.tf individually — the exact same manual-repetition failure mode from the copy-paste-a-whole-directory approach, just narrowed down to a smaller, but still real, slice of each file.
flowchart LR mod["modules/app (shared source)"] devEnv["environments/dev/main.tf"] -->|module call with dev inputs| mod stagingEnv["environments/staging/main.tf"] -->|module call with staging inputs| mod prodEnv["environments/prod/main.tf"] -->|module call with prod inputs| mod devEnv -.->|near-identical backend + provider + source blocks| stagingEnv stagingEnv -.->|near-identical backend + provider + source blocks| prodEnv
Proof: a real per-environment tree, shared module, different plans
Section titled “Proof: a real per-environment tree, shared module, different plans”The two main.tf files above use a real S3 backend per environment (production shape); this course has no AWS credentials, so the live, runnable proof below uses backend "local" {} instead — same module-sharing mechanism, same aws_instance resource, proven with a real terraform plan under AWS mock credentials (skip_credentials_validation etc., the same approach state-management used):
terraform { required_providers { aws = { source = "hashicorp/aws", version = "~> 6.0" } }}
variable "environment" { type = string }variable "instance_type" { type = string }variable "instance_count" { type = number }
resource "aws_instance" "app" { count = var.instance_count ami = "ami-0abcdef1234567890" instance_type = var.instance_type
tags = { Name = "app-${var.environment}-${count.index}" Environment = var.environment }}terraform { backend "local" {} required_providers { aws = { source = "hashicorp/aws", version = "~> 6.0" } }}
provider "aws" { region = "us-east-1" access_key = "mock" secret_key = "mock" skip_credentials_validation = true skip_requesting_account_id = true skip_metadata_api_check = true skip_region_validation = true}
module "app" { source = "../../modules/app"
environment = "dev" instance_type = "t3.micro" instance_count = 1}terraform { backend "local" {} required_providers { aws = { source = "hashicorp/aws", version = "~> 6.0" } }}
provider "aws" { region = "us-east-1" access_key = "mock" secret_key = "mock" skip_credentials_validation = true skip_requesting_account_id = true skip_metadata_api_check = true skip_region_validation = true}
module "app" { source = "../../modules/app"
environment = "prod" instance_type = "m5.large" instance_count = 3}Real terraform plan from inside each environment directory, same shared module, only the input values differing:
$ cd environments/dev && terraform plan + instance_type = "t3.micro" + "Name" = "app-dev-0"Plan: 1 to add, 0 to change, 0 to destroy.
$ cd environments/prod && terraform plan + instance_type = "m5.large" + "Name" = "app-prod-0" + "Name" = "app-prod-1" + "Name" = "app-prod-2"Plan: 3 to add, 0 to change, 0 to destroy.One modules/app directory, zero duplicated resource blocks, and the real plan output proves instance_count and instance_type genuinely came from each environment’s own inputs — dev plans exactly 1 instance, prod plans exactly 3, both from the identical aws_instance block.
🐛 Spot the bug
# environments/dev/main.tf, environments/prod/main.tf — both call this modulemodule "app" {source = "../../modules/app"}Show the answer
Neither call sets environment, instance_type, or instance_count — every argument the module declares as variable with no default. Proven above, this module genuinely requires those three inputs; a call site that omits them fails terraform plan immediately with “Missing required argument,” in both dev and prod simultaneously, since it is the same module source copied nowhere — the bug (or, just as likely, a merge that accidentally dropped the argument block) breaks every environment at once rather than being isolated to one.
terragrunt stack: generating a whole tree of units from one catalog
Section titled “terragrunt stack: generating a whole tree of units from one catalog”Everything above is plain Terraform module calls — this course’s Terragrunt-fundamentals module covered include/dependency, and there is one more Terragrunt feature that fits squarely in this lesson’s subject: terragrunt stack generates several Terragrunt units (not Terraform modules) from one terragrunt.stack.hcl catalog file, which is directory-per-environment’s shared-module idea taken up one more level of abstraction — instead of one module called with different inputs, one unit template generates several independent units, each with its own state.
unit "dev" { source = "./units/app" path = "dev"
values = { environment = "dev" }}
unit "prod" { source = "./units/app" path = "prod"
values = { environment = "prod" }}remote_state { backend = "local" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}
terraform { source = "."}
inputs = { environment = values.environment}A raw values.environment reference like this only resolves inside a unit stack generate actually produced — Terragrunt reads values from a sibling terragrunt.values.hcl file next to terragrunt.hcl, and outside a stack that file simply doesn’t exist yet. Checking a placeholder one into the template itself (the value stack-generated copies always overwrite) keeps the template independently valid:
environment = "app"terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}variable "environment" { type = string }resource "local_file" "app" { filename = "${path.module}/app-${var.environment}.txt" content = "environment=${var.environment}\n"}output "environment" { value = var.environment}Real terragrunt stack generate, run from beside terragrunt.stack.hcl:
$ terragrunt stack generateINFO Generating unit prod from ./terragrunt.stack.hclINFO Generating unit dev from ./terragrunt.stack.hclThis writes a real .terragrunt-stack/dev/ and .terragrunt-stack/prod/ directory, each containing a terragrunt.hcl (copied from units/app) plus a generated terragrunt.values.hcl:
$ cat .terragrunt-stack/dev/terragrunt.values.hcl# Auto-generated by the terragrunt.stack.hcl file by Terragrunt. Do not edit manuallyenvironment = "dev"That file is what makes values.environment inside units/app/terragrunt.hcl resolve — values is a special namespace only populated for units generated by a stack, sourced from exactly this generated file. terragrunt stack run --working-dir .terragrunt-stack -- apply -auto-approve really applies both, and terragrunt stack output aggregates every generated unit’s outputs into one call:
$ terragrunt stack run --non-interactive --working-dir .terragrunt-stack -- apply -auto-approve❯❯ Run Summary 2 units Succeeded 2
$ terragrunt stack outputdev = { environment = "dev"}prod = { environment = "prod"}One real gotcha, proven here rather than asserted: running terragrunt stack run from the directory containing terragrunt.stack.hcl itself (not --working-dir .terragrunt-stack) also discovers the units/app template directory as a third unit, since it has its own real terragrunt.hcl too — and that template has no values object at all outside a stack-generated context, so it fails with a real error: Unknown variable; There is no variable named "values". Every official example sources units from a separate Git ref specifically so the template never coexists as a sibling directory the way this offline demo’s local path does; running commands against .terragrunt-stack directly (as shown above) sidesteps the issue entirely.
Is stacks stable or experimental on the Terragrunt 1.1.0 installed for this course? Checked both ways: docs.terragrunt.com/features/stacks documents terragrunt.stack.hcl, stack generate/run/output with no experimental/beta labeling anywhere on the page, and running every command above produced zero --experiment warnings — compare that to azure-backend in the previous module, which genuinely did require an experiment flag on this same installed version. The stack-dependencies sub-feature (dependency blocks working across stack-generated units specifically) is separately documented as having graduated to general availability in Terragrunt 1.1.0, per the release changelog’s “Six experiments completed” note. Net: terragrunt stack itself is usable without any flag on 1.1.0, and its dependency-block integration graduated in this exact version.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated directory-per-environment or terragrunt.stack.hcl configs, check for:
- A module call missing a required argument the module declares with no default — proven above, this is a real, immediate
terraform planfailure, and because the module is shared, it breaks every environment calling it at once, not just one. - A
unitsource pointing at a local relative path that sits inside the same treestack runwill discover — proven above with a real “no variable named values” error; real catalogs use a pinned Git ref specifically to avoid this. - Code assuming
values.*is available inside a plain (non-stack-generated)terragrunt.hcl— proven above,valuesonly exists in units actually generated byterragrunt stack generate, sourced from a realterragrunt.values.hclfile that command writes. terragrunt stack outputused where the code actually wants a single unit’s output — proven above, it aggregates every generated unit under its own top-level key (dev = {...},prod = {...}), not a flat map.
Mental Model
Section titled “Mental Model”Directory-per-environment is module composition applied at the environment level instead of the resource level — proven above, the exact same aws_instance block inside modules/app produces a 1-instance plan or a 3-instance plan purely from which directory’s inputs called it. terragrunt stack is the same idea one layer up again: instead of one module called with different variable values, one unit template (a whole terragrunt.hcl + backing module) gets stamped out multiple times with different values, each stamped copy becoming its own independently-stated Terragrunt unit — proven above with two real, separately-applied .terragrunt-stack/{dev,prod} directories.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Directory-per-environment with a shared module | Real backend/provider isolation per environment, proven above with separate dev/prod plans from one module | Backend + provider + source boilerplate still duplicated per directory, proven above by how similar the two environment main.tf files are outside their inputs |
terragrunt stack over hand-maintained environment directories | One catalog file (terragrunt.stack.hcl) generates N independent units, proven above with a real stack generate + stack run apply + stack output | A local (non-Git-ref) unit source can itself be discovered as a stray unit by stack run, proven above with a real error — real catalogs avoid this by construction |
Common Misconceptions
Section titled “Common Misconceptions”- “terragrunt stack replaces the module call inside a directory-per-environment setup.” They solve adjacent but different problems: a
modulecall shares Terraform code within one state;terragrunt stackgenerates multiple independent Terragrunt units (each its own state) from one template — proven above,stack outputreturns per-unit results (dev = {...},prod = {...}), not one shared module’s single output set. - “values inside a stack-generated unit’s terragrunt.hcl come from the same place as Terragrunt’s built-in locals.” Proven above:
values.*is populated specifically from a generatedterragrunt.values.hclfile that only exists for unitsstack generateactually produced — a plain, non-stack unit has novaluesobject at all, proven by the real “no variable named values” error above. - “Because it needs no —experiment flag, terragrunt stack has been stable for a long time.” Checked directly above: the base feature carries no experimental labeling on docs.terragrunt.com and needs no flag on this course’s installed 1.1.0, but its
dependency-block integration (stack-dependencies) specifically graduated to general availability in that same version, 1.1.0 — this is a relatively recent stabilization, not a long-settled one.