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
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.

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