The Directory-per-Environment Pattern
The idea in one sentence
Section titled “The idea in one sentence”Instead of one shared configuration switching between workspaces, the directory-per-environment pattern gives dev, staging, and prod each their own real directory with their own backend and provider block, pointed at their own Azure resource group or even their own subscription, while all of them call the same shared module for the actual resources.
Real separation: one directory per environment
Section titled “Real separation: one directory per environment”The shape of the pattern is a set of thin environment directories next to one shared module:
tree environments modulesenvironments├── dev│ ├── backend.tf│ └── main.tf├── staging│ ├── backend.tf│ └── main.tf└── prod ├── backend.tf └── main.tfmodules└── app ├── main.tf ├── variables.tf └── outputs.tfdev and staging are the lighter case: separate resource groups, same subscription. backend.tf in each fixes its own state key and its own subscription_id:
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "dev.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "11111111-1111-1111-1111-111111111111"}terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "staging.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "11111111-1111-1111-1111-111111111111"}prod is the stronger case, reserved for the environment that genuinely needs hard separation: a different subscription_id entirely, so a mistake in dev or staging cannot even authenticate against prod’s resources, let alone modify them.
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "prod.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "22222222-2222-2222-2222-222222222222"}Each environment’s main.tf calls the same shared module, differing only in the input values it passes:
module "app" { source = "../../modules/app"
resource_group_name = "app-dev-rg" location = "eastus" vm_size = "Standard_B1s" instance_count = 1}module "app" { source = "../../modules/app"
resource_group_name = "app-prod-rg" location = "eastus" vm_size = "Standard_D2s_v5" instance_count = 3}What this actually fixes
Section titled “What this actually fixes”This is real isolation, not the naming convention that a workspace gives you. dev and staging each land in their own resource group, so an accidental terraform destroy in one cannot reach resources living in the other’s resource group, and each has its own RBAC scope. prod goes further and sits in its own subscription, which is Azure’s harder boundary — separate billing, separate default quotas, and a genuinely different set of credentials required to touch it at all. Unlike workspaces, there is no shared provider block anywhere that could let a mistaken terraform apply reach across environments: the dev directory’s Terraform run simply has no credential path to prod’s subscription.
The duplication that comes back
Section titled “The duplication that comes back”Look closely at the three backend.tf files above. They are identical in structure — same backend type, same state storage account, same container — differing in exactly two lines: the key and the subscription_id. The same is true of the module "app" block in every main.tf: the source argument is copy-pasted verbatim into every environment, and if that module ever moves, or gets pinned to a version, every environment directory needs the same edit applied by hand. This is the mirror image of the workspace problem. Workspaces solved duplication by sharing everything and isolating almost nothing; directory-per-environment solves isolation by separating everything, including the boilerplate that never actually needed to differ.
flowchart LR devDir["environments/dev"] -->|same subscription, own resource group| subA["Subscription A"] stagingDir["environments/staging"] -->|same subscription, own resource group| subA prodDir["environments/prod"] -->|separate subscription| subB["Subscription B, prod only"] devDir -->|module source = ../../modules/app| sharedModule["modules/app, shared"] stagingDir -->|module source = ../../modules/app| sharedModule prodDir -->|module source = ../../modules/app| sharedModule devDir -.-> dup["backend.tf and provider block nearly identical in every environment"] stagingDir -.-> dup prodDir -.-> dup