Skip to content

The Directory-per-Environment Pattern

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:

Terminal window
tree environments modules
environments
├── dev
│ ├── backend.tf
│ └── main.tf
├── staging
│ ├── backend.tf
│ └── main.tf
└── prod
├── backend.tf
└── main.tf
modules
└── app
├── main.tf
├── variables.tf
└── outputs.tf

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

environments/dev/backend.tf
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"
}
environments/staging/backend.tf
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.

environments/prod/backend.tf
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:

environments/dev/main.tf
module "app" {
source = "../../modules/app"
resource_group_name = "app-dev-rg"
location = "eastus"
vm_size = "Standard_B1s"
instance_count = 1
}
environments/prod/main.tf
module "app" {
source = "../../modules/app"
resource_group_name = "app-prod-rg"
location = "eastus"
vm_size = "Standard_D2s_v5"
instance_count = 3
}

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.

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
Three separate environment directories give dev and staging their own resource group and prod its own subscription, but the backend, provider, and module source blocks are nearly identical copy-paste across all three

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:

modules/app/main.tf
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:

environments/dev/main.tf
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"
}
environments/prod/main.tf
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 validate
Success! The configuration is valid.
$ cd environments/prod && terraform init -backend=false && terraform validate
Success! 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 data
Error: Not enough list items
Attribute 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_count
name = "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):

terragrunt.stack.hcl
unit "dev" {
source = "./units/app"
path = "dev"
values = {
environment = "dev"
}
}
unit "prod" {
source = "./units/app"
path = "prod"
values = {
environment = "prod"
}
}
units/app/terragrunt.hcl
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:

units/app/terragrunt.values.hcl
environment = "app"
units/app/main.tf
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 generate
INFO Generating unit prod from ./terragrunt.stack.hcl
INFO 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 output
dev = {
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.

  • A module call missing a required argument the module declares with no default — proven above, real terraform validate catches 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 unit source pointing at a local relative path inside the same tree stack run will discover as a stray unit — real catalogs use a pinned Git ref specifically to avoid this.

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.

ChoiceBenefitCost
Directory-per-environment with a shared moduleReal backend/provider/subscription isolation per environment, proven above with two independently-validated environment directoriesBackend + provider + source boilerplate still duplicated per directory
terragrunt stack over hand-maintained environment directoriesOne catalog file generates N independent units, proven above with real stack generate/run apply/outputA local (non-Git-ref) unit source can itself be discovered as a stray unit by stack run
  • “azurerm resources can be plan-tested offline the way AWS resources with mock credentials can.” Proven above by contrast: azurerm has no equivalent to AWS’s skip_credentials_validation — this course’s real proof for azurerm is validate, not plan, 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 module call shares Terraform code within one state; terragrunt stack generates multiple independent Terragrunt units from one template, proven above with per-unit stack output results.
What does the directory-per-environment pattern solve that Terraform workspaces do not
Which piece stays shared and DRY across environments in this pattern
What is still being duplicated across environment directories in this pattern despite solving isolation
In this pattern, what is the difference between how dev and staging are isolated versus how prod is isolated
A module's azurerm_linux_virtual_machine resource originally used network_interface_ids = [] and a placeholder public_key string. What did real terraform validate show, proven above?
Why does this lesson use terraform validate rather than terraform plan as its real proof for the azurerm module, unlike the AWS version of this course?