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:
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" } }}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