Directory-per-Environment Pattern
The idea in one sentence
Section titled “The idea in one sentence”The directory-per-environment pattern gives each environment its own directory, its own backend, and its own GCP project for real isolation, while every one of those directories calls the same shared module so the actual infrastructure logic stays in exactly one place.
A real directory per environment, pointed at a real project
Section titled “A real directory per environment, pointed at a real project”Workspaces share one backend, one provider project, and one copy of the .tf code across every environment — which is precisely the weak isolation the previous lesson called out. The directory-per-environment pattern fixes that by giving each environment a genuinely separate root configuration, each with its own backend block and its own provider "google" block pointed at that environment’s own GCP project:
tree environmentsenvironments├── dev│ └── main.tf├── staging│ └── main.tf└── prod └── main.tfterraform { backend "gcs" { bucket = "acme-terraform-state-dev" prefix = "app" }}
provider "google" { project = "acme-dev-123456" region = "us-central1"}
module "app" { source = "../../modules/app" environment = "dev" machine_type = "e2-small" instance_count = 1}terraform { backend "gcs" { bucket = "acme-terraform-state-staging" prefix = "app" }}
provider "google" { project = "acme-staging-234567" region = "us-central1"}
module "app" { source = "../../modules/app" environment = "staging" machine_type = "e2-medium" instance_count = 2}terraform { backend "gcs" { bucket = "acme-terraform-state-prod" prefix = "app" }}
provider "google" { project = "acme-prod-345678" region = "us-central1"}
module "app" { source = "../../modules/app" environment = "prod" machine_type = "e2-standard-4" instance_count = 5}Each directory has its own state bucket and its own project, so a mistake made while terraform apply is running inside environments/dev is confined to acme-dev-123456 — it has no path at all into acme-staging-234567 or acme-prod-345678. That is a hard boundary enforced by GCP IAM and billing, not just a convention anyone has to remember, which is exactly what workspaces could not offer.
One shared module, three different callers
Section titled “One shared module, three different callers”Notice that all three directories call the same source = "../../modules/app" — the actual resources that make up “the app” live in exactly one place:
terraform { required_providers { google = { source = "hashicorp/google", version = "~> 8.0" } }}
variable "environment" { type = string }variable "machine_type" { type = string }variable "instance_count" { type = number }
resource "google_compute_instance" "app" { count = var.instance_count name = "app-${var.environment}-${count.index}" machine_type = var.machine_type zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" }}A bug fix, a new label, or a security-group tightening made inside modules/app/main.tf applies to dev, staging, and prod the next time each environment runs terraform apply — there is exactly one copy of the resource logic to fix, which is the DRY property that plain copy-pasted environment directories from the first lesson never had.
What is still duplicated
Section titled “What is still duplicated”The module itself stayed DRY, but look again at the three main.tf files above: the terraform { backend "gcs" { ... } } block, the provider "google" { ... } block, and the module "app" { source = "../../modules/app" ... } wiring are nearly identical boilerplate, repeated verbatim in every environment directory. Only the bucket name, the project ID, and a handful of module input values actually differ. Add a fourth environment and that entire boilerplate block gets copy-pasted a fourth time; change how state is organized — say, adding a location argument to every backend block — and that change has to be hand-applied to every environment directory that exists, exactly the same manual-repetition risk the very first lesson in this module warned about, just relocated from the module logic to the backend and provider wiring around it.
flowchart TD shared["modules/app (shared source)"] devDir["environments/dev/main.tf"] -->|source| shared stagingDir["environments/staging/main.tf"] -->|source| shared prodDir["environments/prod/main.tf"] -->|source| shared devDir --> devProject["acme-dev-123456"] stagingDir --> stagingProject["acme-staging-234567"] prodDir --> prodProject["acme-prod-345678"] devDir -.->|nearly identical backend + provider block| stagingDir stagingDir -.->|nearly identical backend + provider block| prodDir
Proof: the module and a real environment directory both validate — and two real bugs caught along the way
Section titled “Proof: the module and a real environment directory both validate — and two real bugs caught along the way”The modules/app and environments/dev fences above are real, validated HCL — but the module shown earlier in this lesson did not start out that way. Running terraform init -backend=false && terraform validate against the original module (no variable declarations, no network_interface block) produced four real errors:
Error: Reference to undeclared input variableAn input variable with the name "instance_count" has not been declared.Error: Reference to undeclared input variableAn input variable with the name "environment" has not been declared.Error: Reference to undeclared input variableAn input variable with the name "machine_type" has not been declared.Error: Insufficient network_interface blocksAt least 1 "network_interface" blocks are required.Three undeclared-variable errors plus the same missing-network_interface bug from the previous lesson. The fix — adding variable blocks for environment/machine_type/instance_count and a network_interface { network = "default" } block — is what’s shown above, and it is what actually validates:
$ cd modules/app && terraform init -backend=false && terraform validateSuccess! The configuration is valid.
$ cd environments/dev && terraform init -backend=false && terraform validateSuccess! The configuration is valid.🐛 Spot the bug
resource "google_compute_instance" "app" {count = var.instance_countname = "app-${var.environment}-${count.index}"machine_type = var.machine_typezone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" }}}Show the answer
Proven above with real terraform validate errors: this module references var.instance_count, var.environment, and var.machine_type with no matching variable blocks anywhere in the file — three real “Reference to undeclared input variable” errors — and the google_compute_instance resource has no network_interface block, a fourth real error. A module that “looks complete” because it has a count, a computed name, and a boot_disk is still missing two entire categories of required configuration that only terraform validate catches reliably.
terragrunt stack: generating a whole tree of units from one catalog
Section titled “terragrunt stack: generating a whole tree of units from one catalog”terragrunt stack generates several Terragrunt units from one terragrunt.stack.hcl catalog — the same shared-template idea as the module above, one level up. Proven with a cloud-agnostic local_file unit:
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 only resolves inside a unit stack generate actually produced; a placeholder terragrunt.values.hcl 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 stack generate, stack run, stack output:
$ terragrunt stack generateINFO Generating unit prod from ./terragrunt.stack.hclINFO Generating unit dev from ./terragrunt.stack.hcl
$ 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" }Is stacks stable or experimental on this course’s installed Terragrunt 1.1.0? docs.terragrunt.com/features/stacks documents terragrunt.stack.hcl/stack generate/run/output with no experimental labeling, and every command above produced zero --experiment warnings. Its stack-dependencies sub-feature graduated to general availability in Terragrunt 1.1.0 specifically, per the release changelog.
Reading AI-generated code
Section titled “Reading AI-generated code”- A module referencing
var.*names with no matchingvariableblock — proven above with three real “undeclared input variable” errors. network_interfacemissing on agoogle_compute_instance— proven above and in the previous lesson, a real, provider-enforced error.- A
unitsource pointing at a local relative pathstack runwill discover as a stray unit — real catalogs use a pinned Git ref.
Mental Model
Section titled “Mental Model”Directory-per-environment is module composition applied at the environment level — proven above, the same validated module is called by three environment directories with different inputs. terragrunt stack is the same idea one layer up: one unit template stamped out multiple times with different values.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Directory-per-environment with a shared module | Real backend/provider/project isolation, proven above with independently-validated environment directories | Backend + provider + source boilerplate still duplicated per directory |
terragrunt stack | One catalog generates N independent units, proven above with real stack generate/run apply/output | A local unit source can be discovered as a stray unit by stack run |
Common Misconceptions
Section titled “Common Misconceptions”- “A module that references count, a computed name, and boot_disk correctly must be complete.” Proven above: this exact module still failed real
terraform validatefour separate ways. - “terragrunt stack replaces the module call.” They solve different problems — proven above,
stack outputreturns per-unit results, not one shared module’s output set.