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
Proof: a real shared module, validated for both dev and prod
Section titled “Proof: a real shared module, validated for both dev and prod”azurerm cannot plan offline — unlike AWS’s mock-credential path, azurerm always needs a real authorizer. So the real, runnable proof here is terraform init -backend=false && terraform validate against real hashicorp/azurerm ~> 5.0 resources — proving the module and both environment call sites are genuinely valid HCL that Terraform accepts, not just plausible-looking text:
terraform { required_providers { azurerm = { source = "hashicorp/azurerm", version = "~> 5.0" } }}
variable "resource_group_name" { type = string }variable "location" { type = string }variable "vm_size" { type = string }variable "instance_count" { type = number }variable "admin_ssh_public_key" { type = string }
resource "azurerm_resource_group" "app" { name = var.resource_group_name location = var.location}
resource "azurerm_virtual_network" "app" { name = "${var.resource_group_name}-vnet" address_space = ["10.0.0.0/16"] location = azurerm_resource_group.app.location resource_group_name = azurerm_resource_group.app.name}
resource "azurerm_subnet" "app" { name = "app-subnet" resource_group_name = azurerm_resource_group.app.name virtual_network_name = azurerm_virtual_network.app.name address_prefixes = ["10.0.1.0/24"]}
resource "azurerm_network_interface" "app" { count = var.instance_count name = "app-nic-${count.index}" location = azurerm_resource_group.app.location resource_group_name = azurerm_resource_group.app.name
ip_configuration { name = "internal" subnet_id = azurerm_subnet.app.id private_ip_address_allocation = "Dynamic" }}
resource "azurerm_linux_virtual_machine" "app" { count = var.instance_count name = "app-vm-${count.index}" resource_group_name = azurerm_resource_group.app.name location = azurerm_resource_group.app.location size = var.vm_size admin_username = "azureuser" network_interface_ids = [azurerm_network_interface.app[count.index].id]
disable_password_authentication = true
admin_ssh_key { username = "azureuser" public_key = var.admin_ssh_public_key }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }}Each environment’s backend.tf already supplies the unit’s one provider "azurerm" block, so the real main.tf stays a pure module call — it just needs the SSH key argument the module actually requires:
module "app" { source = "../../modules/app"
resource_group_name = "app-dev-rg" location = "eastus" vm_size = "Standard_B1s" instance_count = 1 admin_ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqIEqHOkp6VjuxNd4l+PksIuf8MtYmMJR3msmrmTlxkbOH1VeUK+p0jBsUqi9nYcgoRfG7bo+3ucOvAIo3lL7cMrRvrQIvyHHGPPkYPzPHy1tS5CYjJYtfavNeQGLJWq5NNGAkK8X1UJyuMfQzo8yCAV83z2BG3QQZxo30zC0giS4OoESLLZ5yAfCMWSRJSXIJxgKQ29ev3s0XhZ5mHIdKMmMlmpy7grMuIP3vqlLIQM8MP+MVKCt9mqdiVHDyw8DTV7dZ119mGupFr6IhVKbY3jQTzuQks0YspudufhFYOr7j4E3Vf20U8zgQNnTmFtkrvHPjZr4EkNmZc0EyOwnr demo"}module "app" { source = "../../modules/app"
resource_group_name = "app-prod-rg" location = "eastus" vm_size = "Standard_D2s_v5" instance_count = 3 admin_ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqIEqHOkp6VjuxNd4l+PksIuf8MtYmMJR3msmrmTlxkbOH1VeUK+p0jBsUqi9nYcgoRfG7bo+3ucOvAIo3lL7cMrRvrQIvyHHGPPkYPzPHy1tS5CYjJYtfavNeQGLJWq5NNGAkK8X1UJyuMfQzo8yCAV83z2BG3QQZxo30zC0giS4OoESLLZ5yAfCMWSRJSXIJxgKQ29ev3s0XhZ5mHIdKMmMlmpy7grMuIP3vqlLIQM8MP+MVKCt9mqdiVHDyw8DTV7dZ119mGupFr6IhVKbY3jQTzuQks0YspudufhFYOr7j4E3Vf20U8zgQNnTmFtkrvHPjZr4EkNmZc0EyOwnr demo"}Both units already have exactly one provider "azurerm" block — the one in their own backend.tf — so nothing here redeclares it. Real validate, both directories:
$ cd environments/dev && terraform init -backend=false && terraform validateSuccess! The configuration is valid.
$ cd environments/prod && terraform init -backend=false && terraform validateSuccess! The configuration is valid.That first attempt was not this clean — the module originally used a bare network_interface_ids = [] with no real NIC and no real SSH key, and terraform validate caught both real, provider-level errors immediately:
Error: decoding "admin_ssh_key.0.public_key" for public key dataError: Not enough list itemsAttribute network_interface_ids requires 1 item minimum, but config has only 0 declared.The fix — a real azurerm_virtual_network/azurerm_subnet/azurerm_network_interface chain feeding a real network_interface_ids list, and a syntactically real (if throwaway) SSH public key — is what’s shown above, and it is what actually validates.
🐛 Spot the bug
resource "azurerm_linux_virtual_machine" "app" {count = var.instance_countname = "app-vm-${count.index}"network_interface_ids = []admin_ssh_key { username = "azureuser" public_key = "not-a-real-key"}}Show the answer
Proven above: network_interface_ids = [] fails real terraform validate with “Attribute network_interface_ids requires 1 item minimum” — an azurerm_linux_virtual_machine cannot exist with zero attached NICs, and a placeholder empty list is not a value Terraform accepts at all, let alone one Azure would. The public_key string is equally real-error territory: azurerm’s provider decodes the SSH key client-side during validate/plan, and a string that isn’t actually SSH key data fails immediately with “decoding … for public key data” — not a plan-time or apply-time surprise, a validate-time one.
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 taken up a level — proven with a cloud-agnostic local_file unit (the mechanism doesn’t depend on which provider the generated units eventually use):
unit "dev" { source = "./units/app" path = "dev"
values = { environment = "dev" }}
unit "prod" { source = "./units/app" path = "prod"
values = { environment = "prod" }}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; checking a placeholder terragrunt.values.hcl into the template (the value stack-generated copies always overwrite) keeps it independently valid:
environment = "app"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, and stack output:
$ terragrunt stack generateINFO Generating unit prod from ./terragrunt.stack.hclINFO 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 outputdev = { environment = "dev"}prod = { environment = "prod"}Is stacks stable or experimental on the Terragrunt 1.1.0 installed for this course? docs.terragrunt.com/features/stacks documents terragrunt.stack.hcl, stack generate/run/output with no experimental/beta labeling, and every command above produced zero --experiment warnings — unlike azure-backend (previous module), which genuinely did require an experiment flag on this same installed version. Its stack-dependencies sub-feature (dependency blocks across stack-generated units) is documented as having graduated to general availability in Terragrunt 1.1.0 specifically, per the release changelog.
Reading AI-generated code
Section titled “Reading AI-generated code”- A module call missing a required argument the module declares with no default — proven above, real
terraform validatecatches this immediately as “Missing required argument,” and because the module is shared, the same bug breaks every environment calling it at once. network_interface_ids = []or similarly “empty for now” placeholder lists on Azure VM-shaped resources — proven above, a real Azure resource with a required attached-resource list genuinely rejects an empty list at validate time.- A
unitsource pointing at a local relative path inside the same treestack runwill discover as a stray unit — real catalogs use a pinned Git ref specifically to avoid this.
Mental Model
Section titled “Mental Model”Directory-per-environment is module composition applied at the environment level — proven above, the same azurerm_linux_virtual_machine block inside modules/app validates for both a 1-instance dev and a 3-instance prod purely from which directory’s inputs called it. terragrunt stack is the same idea one layer up: one unit template gets stamped out multiple times with different values, each stamped copy becoming its own independently-stated Terragrunt unit — proven above with two real, separately-applied .terragrunt-stack/{dev,prod} directories.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Directory-per-environment with a shared module | Real backend/provider/subscription isolation per environment, proven above with two independently-validated environment directories | Backend + provider + source boilerplate still duplicated per directory |
terragrunt stack over hand-maintained environment directories | One catalog file generates N independent units, proven above with real stack generate/run apply/output | A local (non-Git-ref) unit source can itself be discovered as a stray unit by stack run |
Common Misconceptions
Section titled “Common Misconceptions”- “azurerm resources can be plan-tested offline the way AWS resources with mock credentials can.” Proven above by contrast:
azurermhas no equivalent to AWS’sskip_credentials_validation— this course’s real proof for azurerm isvalidate, notplan, because azurerm always needs a real authorizer. - “terragrunt stack replaces the module call inside a directory-per-environment setup.” They solve adjacent but different problems — a
modulecall shares Terraform code within one state;terragrunt stackgenerates multiple independent Terragrunt units from one template, proven above with per-unitstack outputresults.