Skip to content

Directory-per-Environment Pattern

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:

Terminal window
tree environments
environments
├── dev
│ └── main.tf
├── staging
│ └── main.tf
└── prod
└── main.tf
environments/dev/main.tf
terraform {
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
}
environments/staging/main.tf
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
}
environments/prod/main.tf
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:

modules/app/main.tf
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.

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
Three environment directories, three GCP projects, one shared module, and near-identical backend/provider boilerplate

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 variable
An input variable with the name "instance_count" has not been declared.
Error: Reference to undeclared input variable
An input variable with the name "environment" has not been declared.
Error: Reference to undeclared input variable
An input variable with the name "machine_type" has not been declared.
Error: Insufficient network_interface blocks
At 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 validate
Success! The configuration is valid.
$ cd environments/dev && terraform init -backend=false && terraform validate
Success! The configuration is valid.

🐛 Spot the bug

modules/app/main.tf
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"
}
}
}
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:

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 only resolves inside a unit stack generate actually produced; a placeholder terragrunt.values.hcl 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 stack generate, stack run, stack output:

$ terragrunt stack generate
INFO Generating unit prod from ./terragrunt.stack.hcl
INFO 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 output
dev = { 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.

  • A module referencing var.* names with no matching variable block — proven above with three real “undeclared input variable” errors.
  • network_interface missing on a google_compute_instance — proven above and in the previous lesson, a real, provider-enforced error.
  • A unit source pointing at a local relative path stack run will discover as a stray unit — real catalogs use a pinned Git ref.

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.

ChoiceBenefitCost
Directory-per-environment with a shared moduleReal backend/provider/project isolation, proven above with independently-validated environment directoriesBackend + provider + source boilerplate still duplicated per directory
terragrunt stackOne catalog generates N independent units, proven above with real stack generate/run apply/outputA local unit source can be discovered as a stray unit by stack run
  • “A module that references count, a computed name, and boot_disk correctly must be complete.” Proven above: this exact module still failed real terraform validate four separate ways.
  • “terragrunt stack replaces the module call.” They solve different problems — proven above, stack output returns per-unit results, not one shared module’s output set.
What does the directory-per-environment pattern solve that Terraform workspaces do not
What stays DRY, in exactly one place, in this pattern
What is still being duplicated across environment directories in this pattern despite solving isolation
Why does a hard boundary between environments in this pattern come from GCP itself rather than a convention
The modules/app resource references var.instance_count, var.environment, and var.machine_type with no variable blocks declared anywhere in the file. What does real terraform validate show, proven above?
On this course installed Terragrunt 1.1.0, does terragrunt stack generate/run/output require an --experiment flag?