Directory-per-Environment Pattern
The idea in one sentence
Section titled “The idea in one sentence”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:
environments/├── dev/│ └── main.tf├── staging/│ └── main.tf└── prod/ └── main.tfmodules/└── app/ └── main.tfThe 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:
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}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.
What this genuinely solves
Section titled “What this genuinely solves”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.
The DRY problem this reintroduces
Section titled “The DRY problem this reintroduces”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