Skip to content

Directory-per-Environment Pattern

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:

Terminal window
environments/
├── dev/
│ └── main.tf
├── staging/
│ └── main.tf
└── prod/
└── main.tf
modules/
└── app/
└── main.tf

The 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:

environments/dev/main.tf
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
}
environments/prod/main.tf
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.

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.

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
Three isolated environment directories, one shared module, but near-identical boilerplate repeated in every directory

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):

modules/app/main.tf
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
}
}
environments/dev/main.tf
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
}
environments/prod/main.tf
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 module
module "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.

terragrunt.stack.hcl
unit "dev" {
source = "./units/app"
path = "dev"
values = {
environment = "dev"
}
}
unit "prod" {
source = "./units/app"
path = "prod"
values = {
environment = "prod"
}
}
units/app/terragrunt.hcl
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:

units/app/terragrunt.values.hcl
environment = "app"
units/app/main.tf
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 generate
INFO Generating unit prod from ./terragrunt.stack.hcl
INFO Generating unit dev from ./terragrunt.stack.hcl

This 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 manually
environment = "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 output
dev = {
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.

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 plan failure, and because the module is shared, it breaks every environment calling it at once, not just one.
  • A unit source pointing at a local relative path that sits inside the same tree stack run will 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, values only exists in units actually generated by terragrunt stack generate, sourced from a real terragrunt.values.hcl file that command writes.
  • terragrunt stack output used 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.

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.

ChoiceBenefitCost
Directory-per-environment with a shared moduleReal backend/provider isolation per environment, proven above with separate dev/prod plans from one moduleBackend + 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 directoriesOne catalog file (terragrunt.stack.hcl) generates N independent units, proven above with a real stack generate + stack run apply + stack outputA 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
  • “terragrunt stack replaces the module call inside a directory-per-environment setup.” They solve adjacent but different problems: a module call shares Terraform code within one state; terragrunt stack generates multiple independent Terragrunt units (each its own state) from one template — proven above, stack output returns 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 generated terragrunt.values.hcl file that only exists for units stack generate actually produced — a plain, non-stack unit has no values object 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.
What does the directory-per-environment pattern solve that Terraform workspaces do not
What stays DRY (written once, reused everywhere) in the directory-per-environment pattern
What is still being duplicated across environment directories in this pattern, despite solving isolation
Why can environments/dev and environments/prod safely use two entirely different AWS accounts in this pattern
modules/app declares instance_type and instance_count as required variables with no default. A generated module "app" call sets neither. What happens, proven above?
On this course's installed Terragrunt 1.1.0, does terragrunt stack generate/run/output require an --experiment flag?