Terraform Interview Questions and Answers Part 6 - Environments and Workspaces

Terraform Interview Q&A · Part 6 of 10

In this post we are going to cover environments and workspaces in Terraform. These are 7 real Terraform interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario: the company wants a new environment in a new region, in one day.

An environment is a full copy of the platform, like dev, test or prod. The hard part is not creating the copies. The hard part is keeping them separate, keeping them the same where they should be the same, and making it hard to change the wrong one. Interviewers ask about this because every data platform team gets it wrong at least once.

Part 6 of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT.

The short answer

Say this first

Give every environment its own state, its own backend key and its own identity. Keep the code in one module and the environments as thin folders where only the tfvars differ. CLI workspaces fit short-lived copies that share a backend, not dev and prod. Share values through outputs or data sources, and make a new region a new map entry, not new code.

Let's set up the project

We use a small Terraform 1.17 project. The resources are terraform_data stand-ins for a storage account, its containers and a subnet, so every plan and apply is real and nothing is created in Azure. The Azure code is checked with terraform validate. Here are the starting files.

modules/lake/variables.tf
variable "environment" {
  type = string
}

variable "replication" {
  type = string
}

variable "region" {
  type = object({
    location = string
    short    = string
    cidr     = string
  })
}

variable "tags" {
  type = map(string)
}
modules/lake/main.tf
locals {
  suffix = "${var.environment}${var.region.short}"
}

# stand-ins for azurerm_storage_account, its containers and a subnet
resource "terraform_data" "account" {
  input = {
    name        = "stsalesdata${local.suffix}"
    location    = var.region.location
    replication = var.replication
    tags        = var.tags
  }
}

resource "terraform_data" "container" {
  for_each = toset(["raw", "curated"])
  input    = "${terraform_data.account.input.name}/${each.key}"
}

resource "terraform_data" "subnet" {
  input = {
    name = "snet-lake-${local.suffix}"
    cidr = var.region.cidr
  }
}
modules/lake/outputs.tf
output "account_name" {
  value = terraform_data.account.input.name
}
onestate/main.tf
locals {
  tags = { owner = "sales-data", cost_center = "cc-1001" }
}

module "dev" {
  source      = "../modules/lake"
  environment = "dev"
  replication = "LRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.20.0.0/20"
  }
}

module "prod" {
  source      = "../modules/lake"
  environment = "prod"
  replication = "GRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.40.0.0/20"
  }
}
ws/main.tf
variable "environment" {
  type = string
}

resource "terraform_data" "lake" {
  input = "stsalesdata${terraform.workspace}"

  lifecycle {
    precondition {
      condition     = var.environment == terraform.workspace
      error_message = "The tfvars file is for a different workspace."
    }
  }
}
ws/dev.tfvars
environment = "dev"
ws/prod.tfvars
environment = "prod"
envs/dev/main.tf
module "lake" {
  source   = "../../modules/lake"
  for_each = var.regions

  environment = var.environment
  replication = var.replication
  tags        = { env = var.environment, owner = "sales-data" }
  region = {
    location = each.key
    short    = each.value.short
    cidr     = cidrsubnet(var.address_space, 4, each.value.index)
  }
}
envs/dev/variables.tf
variable "environment" {
  type = string
}

variable "replication" {
  type = string
}

variable "address_space" {
  type = string
}

variable "regions" {
  type = map(object({
    short = string
    index = number
  }))
}
envs/dev/terraform.tfvars
environment   = "dev"
replication   = "LRS"
address_space = "10.20.0.0/16"
regions = {
  eastus2 = { short = "eus2", index = 0 }
}
envs/prod/main.tf
module "lake" {
  source   = "../../modules/lake"
  for_each = var.regions

  environment = var.environment
  replication = var.replication
  tags        = { env = var.environment, owner = "sales-data" }
  region = {
    location = each.key
    short    = each.value.short
    cidr     = cidrsubnet(var.address_space, 4, each.value.index)
  }
}
envs/prod/variables.tf
variable "environment" {
  type = string
}

variable "replication" {
  type = string
}

variable "address_space" {
  type = string
}

variable "regions" {
  type = map(object({
    short = string
    index = number
  }))
}
envs/prod/terraform.tfvars
environment   = "prod"
replication   = "GRS"
address_space = "10.40.0.0/16"
regions = {
  eastus2 = { short = "eus2", index = 0 }
}
stacks/network/main.tf
resource "terraform_data" "vnet" {
  input = {
    name          = "vnet-sales-data-dev"
    address_space = "10.20.0.0/16"
  }
}

output "vnet_name" {
  value = terraform_data.vnet.input.name
}

output "lake_subnet_cidr" {
  value = cidrsubnet(terraform_data.vnet.input.address_space, 8, 1)
}
stacks/lake/main.tf
data "terraform_remote_state" "network" {
  backend = "local"
  config = {
    path = "../network/terraform.tfstate"
  }
}

locals {
  net = data.terraform_remote_state.network.outputs
}

resource "terraform_data" "private_endpoint" {
  input = {
    vnet   = local.net.vnet_name
    subnet = local.net.lake_subnet_cidr
  }
}
azure/prod/versions.tf
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.11"
    }
  }
}
azure/prod/backend.tf
# prod state: its own storage account, in the prod subscription
terraform {
  backend "azurerm" {
    use_oidc             = true
    use_azuread_auth     = true
    storage_account_name = "sttfstateprod"
    container_name       = "tfstate"
    key                  = "lake/prod.terraform.tfstate"
  }
}
azure/prod/providers.tf
provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
}

variable "subscription_id" {
  description = "Prod subscription; dev identities have no access"
  type        = string
}
azure/prod/network.tf
# read one value by name, not the whole network state
data "azurerm_subnet" "lake" {
  name                 = "snet-lake"
  virtual_network_name = "vnet-sales-data-prod"
  resource_group_name  = "rg-sales-network-prod"
}

output "lake_subnet_id" {
  value = data.azurerm_subnet.lake.id
}

Question 1: The team keeps dev and prod in one state file. Why is that risky, and what are the options?

The short answer is blast radius. With dev and prod in one state, every plan and every apply covers both.

Let's see it. The onestate root calls the lake module twice, once for dev and once for prod. We run terraform state list on it.

Terminal
$ terraform -chdir=onestate state list
module.dev.terraform_data.account
module.dev.terraform_data.container["curated"]
module.dev.terraform_data.container["raw"]
module.dev.terraform_data.subnet
module.prod.terraform_data.account
module.prod.terraform_data.container["curated"]
module.prod.terraform_data.container["raw"]
module.prod.terraform_data.subnet

Dev and prod sit side by side in one state: four resources under module.dev and four under module.prod.

Now someone changes a shared tag, the cost center, from cc-1001 to cc-2002. We run terraform plan.

onestate/main.tf
locals {
  tags = { owner = "sales-data", cost_center = "cc-2002" }
}

module "dev" {
  source      = "../modules/lake"
  environment = "dev"
  replication = "LRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.20.0.0/20"
  }
}

module "prod" {
  source      = "../modules/lake"
  environment = "prod"
  replication = "GRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.40.0.0/20"
  }
}
Terminal
$ terraform -chdir=onestate plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.dev.terraform_data.account will be updated in-place
  ~ resource "terraform_data" "account" {
        id     = "<uuid>"
      ~ input  = {
            name        = "stsalesdatadeveus2"
          ~ tags        = {
              ~ cost_center = "cc-1001" -> "cc-2002"
                owner       = "sales-data"
            }
            # (2 unchanged attributes hidden)
        }
      ~ output = {
          - location    = "eastus2"
          - name        = "stsalesdatadeveus2"
          - replication = "LRS"
          - tags        = {
              - cost_center = "cc-1001"
              - owner       = "sales-data"
            }
        } -> (known after apply)
    }

  # module.prod.terraform_data.account will be updated in-place
  ~ resource "terraform_data" "account" {
        id     = "<uuid>"
      ~ input  = {
            name        = "stsalesdataprodeus2"
          ~ tags        = {
              ~ cost_center = "cc-1001" -> "cc-2002"
                owner       = "sales-data"
            }
            # (2 unchanged attributes hidden)
        }
      ~ output = {
          - location    = "eastus2"
          - name        = "stsalesdataprodeus2"
          - replication = "GRS"
          - tags        = {
              - cost_center = "cc-1001"
              - owner       = "sales-data"
            }
        } -> (known after apply)
    }

Plan: 0 to add, 2 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

One plan updates module.dev.terraform_data.account and module.prod.terraform_data.account: Plan: 0 to add, 2 to change, 0 to destroy. A routine change now needs a production review, or it slips into prod unnoticed.

It gets worse:

  • One lock: a dev apply blocks a prod hotfix
  • One identity: it must be allowed to change prod
  • One state: whoever reads it sees prod

So what are the options? There are three common ones.

OptionStateIsolation
CLI workspacesone per workspace, same backendsame backend and credentials
Folder per environmentown backend key per folderown state, identity, approvals
HCP Terraform workspacesone per workspaceown variables and permissions
Real project

I split by environment, and also by layer, like network, lake and compute. Every state stays small and has one owner.

Question 2: A teammate wants to use Terraform workspaces for dev, test and prod. When are workspaces a good fit?

A CLI workspace gives one configuration several states in the same backend. That is a good fit for short-lived copies, not for dev and prod.

The current workspace name is available as terraform.workspace, so a resource name can include it. In the ws folder the lake name is stsalesdata${terraform.workspace}. Let's create a dev and a prod workspace and list them.

Terminal
$ terraform -chdir=ws workspace new dev
Created and switched to workspace "dev"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
$ terraform -chdir=ws workspace new prod
Created and switched to workspace "prod"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
$ terraform -chdir=ws workspace list
  default
  dev
* prod

Each new workspace starts with an empty state, and workspace list marks the current one, prod, with a star.

Now we apply in prod, switch to dev, and apply again. Then we look inside terraform.tfstate.d.

Terminal
$ terraform -chdir=ws apply -auto-approve -var-file=prod.tfvars
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # terraform_data.lake will be created
  + resource "terraform_data" "lake" {
      + id     = (known after apply)
      + input  = "stsalesdataprod"
      + output = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.
terraform_data.lake: Creating...
terraform_data.lake: Creation complete after <time> [id=<uuid>]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform -chdir=ws workspace select dev
Switched to workspace "dev".
$ terraform -chdir=ws apply -auto-approve -var-file=dev.tfvars
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # terraform_data.lake will be created
  + resource "terraform_data" "lake" {
      + id     = (known after apply)
      + input  = "stsalesdatadev"
      + output = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.
terraform_data.lake: Creating...
terraform_data.lake: Creation complete after <time> [id=<uuid>]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ ls ws/terraform.tfstate.d
dev
prod

We get stsalesdataprod and stsalesdatadev, and the local backend keeps one state per workspace, in the dev and prod folders under terraform.tfstate.d.

So when do workspaces fit? A good fit is a short-lived copy with the same backend and credentials, like a test copy per feature branch. A bad fit is dev and prod. HashiCorp's docs say CLI workspaces share one backend, so they are not an isolation mechanism when environments need separate credentials and access controls. With the azurerm backend, each workspace is just another blob in the same container.

Pitfall

The selected workspace is not visible in the code, and that is how people apply to the wrong one. HCP Terraform workspaces are a different thing, with their own variables and permissions.

Question 3: Your manager asks how to keep environments in separate folders without copying code. How do you structure it?

Put the code in one module, and keep each environment as a thin root folder that only calls it.

Let's list the Terraform files in envs and modules.

Terminal
$ find envs modules -name '*.tf*' | sort
envs/dev/main.tf
envs/dev/terraform.tfvars
envs/dev/variables.tf
envs/prod/main.tf
envs/prod/terraform.tfvars
envs/prod/variables.tf
modules/lake/main.tf
modules/lake/outputs.tf
modules/lake/variables.tf

The dev and prod folders each have a main.tf, a variables.tf and a terraform.tfvars. The logic lives in modules/lake.

The environment main.tf is one module block with for_each over regions. The tfvars hold what really differs: the environment name, the replication and the address space.

Each folder is its own root with its own state. Let's plan the prod folder.

Terminal
$ terraform -chdir=envs/prod plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.lake["eastus2"].terraform_data.account will be created
  + resource "terraform_data" "account" {
      + id     = (known after apply)
      + input  = {
          + location    = "eastus2"
          + name        = "stsalesdataprodeus2"
          + replication = "GRS"
          + tags        = {
              + env   = "prod"
              + owner = "sales-data"
            }
        }
      + output = (known after apply)
    }

  # module.lake["eastus2"].terraform_data.container["curated"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = "stsalesdataprodeus2/curated"
      + output = (known after apply)
    }

  # module.lake["eastus2"].terraform_data.container["raw"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = "stsalesdataprodeus2/raw"
      + output = (known after apply)
    }

  # module.lake["eastus2"].terraform_data.subnet will be created
  + resource "terraform_data" "subnet" {
      + id     = (known after apply)
      + input  = {
          + cidr = "10.40.0.0/20"
          + name = "snet-lake-prodeus2"
        }
      + output = (known after apply)
    }

Plan: 4 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Terraform plans the prod account stsalesdataprodeus2 with GRS, the raw and curated containers, and the subnet 10.40.0.0/20: Plan: 4 to add, 0 to change, 0 to destroy.

Here the state is local. In Azure, each folder has a backend "azurerm" block with its own key, and prod ideally gets its own storage account in the prod subscription.

Pitfall

Do not let if environment == prod checks spread through the module. Put every difference in a variable, so all environments run the same code path.

Question 4: Someone applied dev settings to production by mistake. How do you make the target environment hard to get wrong?

Make the target come from where you run, not from a flag you type.

First, the prod folder. In azure/prod there is an azurerm backend in its own storage account, sttfstateprod, and a prod subscription_id, which azurerm needs for every plan and apply. We run terraform validate against the real provider.

Terminal
$ terraform -chdir=azure/prod validate
Success! The configuration is valid.

Success! The configuration is valid.

Then we add guards in code. The ws configuration has a precondition that compares var.environment with terraform.workspace. Let's select the prod workspace and plan with the dev tfvars.

Terminal (expected error)
$ terraform -chdir=ws workspace select prod
Switched to workspace "prod".
$ terraform -chdir=ws plan -var-file=dev.tfvars
terraform_data.lake: Refreshing state... [id=<uuid>]

Planning failed. Terraform encountered an error while generating this plan.

Error: Resource precondition failed

  on main.tf line 10, in resource "terraform_data" "lake":
  10:       condition     = var.environment == terraform.workspace
    ├────────────────
    │ terraform.workspace is "prod"
    │ var.environment is "dev"

The tfvars file is for a different workspace.

The plan stops before anything changes: terraform.workspace is "prod", var.environment is "dev", and our message The tfvars file is for a different workspace.

Saved plans are bound to their workspace too. Since Terraform 1.15, applying a prod plan file in the dev workspace fails, even when the plan has no changes. Let's save a prod plan, switch to dev and apply it.

Terminal (expected error)
$ terraform -chdir=ws plan -var-file=prod.tfvars -out=prod.tfplan
terraform_data.lake: Refreshing state... [id=<uuid>]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
$ terraform -chdir=ws workspace select dev
Switched to workspace "dev".
$ terraform -chdir=ws apply prod.tfplan
Error: The plan file describes changes to the "prod" workspace, but the "dev" workspace is currently in
  use.

Applying this plan with the incorrect workspace selected could result in state being stored in an
  unexpected location, or a downstream error when Terraform attempts apply a plan using the other
    workspace's state. If you'd like to continue to use the plan file, you must run "terraform
      workspace select prod" to select the matching workspace.
In future make sure the selected workspace is not changed between creating and applying a plan file.

The plan itself says No changes., and the apply still fails: The plan file describes changes to the "prod" workspace, but the "dev" workspace is currently in use.

In the pipeline:

  • Own stage for prod: its own federated identity and an approval
  • Apply the reviewed plan file: exactly what was approved
  • Dev identity: has no rights in prod
My rule

Nobody applies to prod from a laptop. If people can't reach prod by accident, they can't break it by accident.

Question 5: A team asks how to share values between environment stacks without one big state. What do you do?

Publish outputs from the stack that owns the thing, and read only what you need in the other stack.

The network stack is already applied. Let's run terraform output on it.

Terminal
$ terraform -chdir=stacks/network output
lake_subnet_cidr = "10.20.1.0/24"
vnet_name = "vnet-sales-data-dev"

It publishes vnet_name = "vnet-sales-data-dev" and lake_subnet_cidr = "10.20.1.0/24".

The lake stack reads them with the terraform_remote_state data source. We run terraform plan in stacks/lake.

Terminal
$ terraform -chdir=stacks/lake plan
data.terraform_remote_state.network: Reading...
data.terraform_remote_state.network: Read complete after <time>

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # terraform_data.private_endpoint will be created
  + resource "terraform_data" "private_endpoint" {
      + id     = (known after apply)
      + input  = {
          + subnet = "10.20.1.0/24"
          + vnet   = "vnet-sales-data-dev"
        }
      + output = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The private endpoint gets both values, 10.20.1.0/24 and vnet-sales-data-dev. Two states, and no copy and paste of names.

The catch: terraform_remote_state needs read access to the whole state snapshot, not just the outputs. Whoever can read the network outputs can read everything in that state.

So for shared values, I prefer other ways:

  • Data sources: look the object up by name, for example azurerm_subnet
  • Key Vault or App Configuration: publish the values explicitly
  • HCP Terraform: the tfe_outputs data source

The azure/prod folder from question 4 already does the first one. Its network.tf looks up the lake subnet with data "azurerm_subnet", and the validate we ran there passed against the real provider.

Tip

Treat outputs like an API. Clear names, stable values, and a deprecation period before you remove one: tell the consuming teams first, then delete it.

Question 6: Prod and dev slowly drifted apart in code. How do you keep environments consistent?

Make drift in code hard by design: one module, identical thin environment folders, and differences only in tfvars.

And check it. Let's diff the dev and prod files.

Terminal
$ diff envs/dev/main.tf envs/prod/main.tf && echo "main.tf: identical"
main.tf: identical
$ diff envs/dev/variables.tf envs/prod/variables.tf && echo "variables.tf: identical"
variables.tf: identical
$ diff envs/dev/terraform.tfvars envs/prod/terraform.tfvars
1,3c1,3
< environment   = "dev"
< replication   = "LRS"
< address_space = "10.20.0.0/16"
---
> environment   = "prod"
> replication   = "GRS"
> address_space = "10.40.0.0/16"

main.tf: identical and variables.tf: identical. The tfvars diff shows the only allowed differences: environment, replication and address_space.

When the module changes, every environment gets the same change. Both environment folders are already applied. Now the module adds a sandbox container, and we plan both folders with -detailed-exitcode.

modules/lake/main.tf
locals {
  suffix = "${var.environment}${var.region.short}"
}

# stand-ins for azurerm_storage_account, its containers and a subnet
resource "terraform_data" "account" {
  input = {
    name        = "stsalesdata${local.suffix}"
    location    = var.region.location
    replication = var.replication
    tags        = var.tags
  }
}

resource "terraform_data" "container" {
  for_each = toset(["raw", "curated", "sandbox"])
  input    = "${terraform_data.account.input.name}/${each.key}"
}

resource "terraform_data" "subnet" {
  input = {
    name = "snet-lake-${local.suffix}"
    cidr = var.region.cidr
  }
}
Terminal
$ terraform -chdir=envs/dev plan -detailed-exitcode -refresh=false; echo "exit code: $?"
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.lake["eastus2"].terraform_data.container["sandbox"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = "stsalesdatadeveus2/sandbox"
      + output = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
exit code: 2
$ terraform -chdir=envs/prod plan -detailed-exitcode -refresh=false; echo "exit code: $?"
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.lake["eastus2"].terraform_data.container["sandbox"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = "stsalesdataprodeus2/sandbox"
      + output = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
exit code: 2

Both plans show the same new container, stsalesdatadeveus2/sandbox and stsalesdataprodeus2/sandbox, and both print exit code: 2, which means changes are pending.

Then we promote the same commit:

  • Merge: one commit, one module version
  • Apply to dev, and check it
  • Plan prod with the same commit: approval, then apply the saved plan
  • Pinned module versions: bump dev first, then prod
Drift check

Run plan -detailed-exitcode every night for every environment, with refresh on, so it reads the real resources. Exit code 2 on a day with no merges means someone changed something outside the pipeline.

Scenario 7: The company wants a new environment for a new region in one day. How do you design for that?

Design for it before anyone asks: nothing region-specific is hard-coded. The region, its short name and its network index are inputs, and the names and address ranges are computed.

So a new region is data, not code. First we take the sandbox container back out of the module, so the plan shows only the new region. Then prod gets one more map entry in its tfvars: westeurope = { short = "weu", index = 1 }. We run terraform plan in the prod folder.

modules/lake/main.tf
locals {
  suffix = "${var.environment}${var.region.short}"
}

# stand-ins for azurerm_storage_account, its containers and a subnet
resource "terraform_data" "account" {
  input = {
    name        = "stsalesdata${local.suffix}"
    location    = var.region.location
    replication = var.replication
    tags        = var.tags
  }
}

resource "terraform_data" "container" {
  for_each = toset(["raw", "curated"])
  input    = "${terraform_data.account.input.name}/${each.key}"
}

resource "terraform_data" "subnet" {
  input = {
    name = "snet-lake-${local.suffix}"
    cidr = var.region.cidr
  }
}
envs/prod/terraform.tfvars
environment   = "prod"
replication   = "GRS"
address_space = "10.40.0.0/16"
regions = {
  eastus2    = { short = "eus2", index = 0 }
  westeurope = { short = "weu", index = 1 }
}
Terminal
$ terraform -chdir=envs/prod plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.lake["westeurope"].terraform_data.account will be created
  + resource "terraform_data" "account" {
      + id     = (known after apply)
      + input  = {
          + location    = "westeurope"
          + name        = "stsalesdataprodweu"
          + replication = "GRS"
          + tags        = {
              + env   = "prod"
              + owner = "sales-data"
            }
        }
      + output = (known after apply)
    }

  # module.lake["westeurope"].terraform_data.container["curated"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = "stsalesdataprodweu/curated"
      + output = (known after apply)
    }

  # module.lake["westeurope"].terraform_data.container["raw"] will be created
  + resource "terraform_data" "container" {
      + id     = (known after apply)
      + input  = "stsalesdataprodweu/raw"
      + output = (known after apply)
    }

  # module.lake["westeurope"].terraform_data.subnet will be created
  + resource "terraform_data" "subnet" {
      + id     = (known after apply)
      + input  = {
          + cidr = "10.40.16.0/20"
          + name = "snet-lake-prodweu"
        }
      + output = (known after apply)
    }

Plan: 4 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan creates stsalesdataprodweu, its raw and curated containers, and the subnet snet-lake-prodweu, and nothing else: Plan: 4 to add, 0 to change, 0 to destroy. cidrsubnet takes block 1 of the prod address space, 10.40.16.0/20, so the two regions can't overlap.

The checklist for that day:

  • Quota and services: available in the region
  • Data residency: what may live where
  • Role assignments: for the new identities
  • Pipeline stage: plan, approve, apply
  • Own blast radius? Then its own folder and state, with the same thin pattern
At scale

HCP Terraform Stacks, generally available since September 2025, are built for exactly this: one set of components, deployed many times across regions and subscriptions.

Key points to remember

  • One state per environment: smaller blast radius, own lock, own identity
  • CLI workspaces: same backend and credentials, good for short-lived copies
  • One module, thin env folders: only the tfvars differ
  • Guards: own subscription and identity, preconditions, saved plans per workspace
  • Share values with outputs or data sources; a new region is a new map entry

The full script

Here is a Python script that writes all the files and runs every command in order. You need Terraform 1.17 on your PATH; run it in an empty folder.

Show the full script that writes the files and runs every step
Full script - writes the Terraform files and runs every step (Terraform 1.17 on your PATH), run it with Python
# ========================================================================
# Terraform Interview Q&A #6 - Environments and workspaces
# TechBrothersIT. Names and sample values are invented for teaching.
# Every step below was run with Terraform 1.17.0; the outputs in the video and the blog post are the real outputs.
# Providers used: hashicorp/random, null, local, time (they create nothing in any cloud). Azure and Databricks
# code in this series is checked with terraform validate only: nothing is created in a real subscription.
# To run it yourself (Linux, macOS or WSL; some steps use grep / cat):
#   1. Install Terraform 1.17 from developer.hashicorp.com/terraform/install and put it on your PATH.
#   2. python Terraform_Interview_QA_06_Code.py   in an empty folder (terraform init downloads the providers).
# ========================================================================

import os, subprocess

def write(path, text):
    if text is None:
        if os.path.exists(path): os.remove(path)
        return
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(text.strip("\n") + "\n")

def run(cmd, expect_error=False, allow_error=False, env=None):
    print("\n$ " + cmd, flush=True)
    rc = subprocess.run(cmd, shell=True, env=dict(os.environ, **(env or {}))).returncode
    if rc != 0 and not (expect_error or allow_error):
        raise SystemExit("stopped: this command was expected to succeed")

# Step 0: the starting files
write('modules/lake/variables.tf', """\
variable "environment" {
  type = string
}

variable "replication" {
  type = string
}

variable "region" {
  type = object({
    location = string
    short    = string
    cidr     = string
  })
}

variable "tags" {
  type = map(string)
}
""")
write('modules/lake/main.tf', """\
locals {
  suffix = "${var.environment}${var.region.short}"
}

# stand-ins for azurerm_storage_account, its containers and a subnet
resource "terraform_data" "account" {
  input = {
    name        = "stsalesdata${local.suffix}"
    location    = var.region.location
    replication = var.replication
    tags        = var.tags
  }
}

resource "terraform_data" "container" {
  for_each = toset(["raw", "curated"])
  input    = "${terraform_data.account.input.name}/${each.key}"
}

resource "terraform_data" "subnet" {
  input = {
    name = "snet-lake-${local.suffix}"
    cidr = var.region.cidr
  }
}
""")
write('modules/lake/outputs.tf', """\
output "account_name" {
  value = terraform_data.account.input.name
}
""")
write('onestate/main.tf', """\
locals {
  tags = { owner = "sales-data", cost_center = "cc-1001" }
}

module "dev" {
  source      = "../modules/lake"
  environment = "dev"
  replication = "LRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.20.0.0/20"
  }
}

module "prod" {
  source      = "../modules/lake"
  environment = "prod"
  replication = "GRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.40.0.0/20"
  }
}
""")
write('ws/main.tf', """\
variable "environment" {
  type = string
}

resource "terraform_data" "lake" {
  input = "stsalesdata${terraform.workspace}"

  lifecycle {
    precondition {
      condition     = var.environment == terraform.workspace
      error_message = "The tfvars file is for a different workspace."
    }
  }
}
""")
write('ws/dev.tfvars', """\
environment = "dev"
""")
write('ws/prod.tfvars', """\
environment = "prod"
""")
write('envs/dev/main.tf', """\
module "lake" {
  source   = "../../modules/lake"
  for_each = var.regions

  environment = var.environment
  replication = var.replication
  tags        = { env = var.environment, owner = "sales-data" }
  region = {
    location = each.key
    short    = each.value.short
    cidr     = cidrsubnet(var.address_space, 4, each.value.index)
  }
}
""")
write('envs/dev/variables.tf', """\
variable "environment" {
  type = string
}

variable "replication" {
  type = string
}

variable "address_space" {
  type = string
}

variable "regions" {
  type = map(object({
    short = string
    index = number
  }))
}
""")
write('envs/dev/terraform.tfvars', """\
environment   = "dev"
replication   = "LRS"
address_space = "10.20.0.0/16"
regions = {
  eastus2 = { short = "eus2", index = 0 }
}
""")
write('envs/prod/main.tf', """\
module "lake" {
  source   = "../../modules/lake"
  for_each = var.regions

  environment = var.environment
  replication = var.replication
  tags        = { env = var.environment, owner = "sales-data" }
  region = {
    location = each.key
    short    = each.value.short
    cidr     = cidrsubnet(var.address_space, 4, each.value.index)
  }
}
""")
write('envs/prod/variables.tf', """\
variable "environment" {
  type = string
}

variable "replication" {
  type = string
}

variable "address_space" {
  type = string
}

variable "regions" {
  type = map(object({
    short = string
    index = number
  }))
}
""")
write('envs/prod/terraform.tfvars', """\
environment   = "prod"
replication   = "GRS"
address_space = "10.40.0.0/16"
regions = {
  eastus2 = { short = "eus2", index = 0 }
}
""")
write('stacks/network/main.tf', """\
resource "terraform_data" "vnet" {
  input = {
    name          = "vnet-sales-data-dev"
    address_space = "10.20.0.0/16"
  }
}

output "vnet_name" {
  value = terraform_data.vnet.input.name
}

output "lake_subnet_cidr" {
  value = cidrsubnet(terraform_data.vnet.input.address_space, 8, 1)
}
""")
write('stacks/lake/main.tf', """\
data "terraform_remote_state" "network" {
  backend = "local"
  config = {
    path = "../network/terraform.tfstate"
  }
}

locals {
  net = data.terraform_remote_state.network.outputs
}

resource "terraform_data" "private_endpoint" {
  input = {
    vnet   = local.net.vnet_name
    subnet = local.net.lake_subnet_cidr
  }
}
""")
write('azure/prod/versions.tf', """\
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.11"
    }
  }
}
""")
write('azure/prod/backend.tf', """\
# prod state: its own storage account, in the prod subscription
terraform {
  backend "azurerm" {
    use_oidc             = true
    use_azuread_auth     = true
    storage_account_name = "sttfstateprod"
    container_name       = "tfstate"
    key                  = "lake/prod.terraform.tfstate"
  }
}
""")
write('azure/prod/providers.tf', """\
provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
}

variable "subscription_id" {
  description = "Prod subscription; dev identities have no access"
  type        = string
}
""")
write('azure/prod/network.tf', """\
# read one value by name, not the whole network state
data "azurerm_subnet" "lake" {
  name                 = "snet-lake"
  virtual_network_name = "vnet-sales-data-prod"
  resource_group_name  = "rg-sales-network-prod"
}

output "lake_subnet_id" {
  value = data.azurerm_subnet.lake.id
}
""")
run('terraform fmt -check -recursive')
run('terraform -chdir=onestate init')
run('terraform -chdir=onestate apply -auto-approve')
run('terraform -chdir=ws init')
run('terraform -chdir=envs/dev init')
run('terraform -chdir=envs/prod init')
run('terraform -chdir=stacks/network init')
run('terraform -chdir=stacks/network apply -auto-approve')
run('terraform -chdir=stacks/lake init')
run('terraform -chdir=azure/prod init -backend=false')

# Step 1: Dev and prod in one state
run('terraform -chdir=onestate state list')

# Step 2: Change a shared tag: both environments change
write('onestate/main.tf', """\
locals {
  tags = { owner = "sales-data", cost_center = "cc-2002" }
}

module "dev" {
  source      = "../modules/lake"
  environment = "dev"
  replication = "LRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.20.0.0/20"
  }
}

module "prod" {
  source      = "../modules/lake"
  environment = "prod"
  replication = "GRS"
  tags        = local.tags
  region = {
    location = "eastus2"
    short    = "eus2"
    cidr     = "10.40.0.0/20"
  }
}
""")
run('terraform -chdir=onestate plan -refresh=false')

# Step 3: Create dev and prod workspaces
run('terraform -chdir=ws workspace new dev')
run('terraform -chdir=ws workspace new prod')
run('terraform -chdir=ws workspace list')

# Step 4: One state file per workspace
run('terraform -chdir=ws apply -auto-approve -var-file=prod.tfvars')
run('terraform -chdir=ws workspace select dev')
run('terraform -chdir=ws apply -auto-approve -var-file=dev.tfvars')
run('ls ws/terraform.tfstate.d')

# Step 5: Folders per environment, one module
run("find envs modules -name '*.tf*' | sort")

# Step 6: Plan the prod folder
run('terraform -chdir=envs/prod plan')

# Step 7: The Azure prod folder: azurerm backend, prod subscription
run('terraform -chdir=azure/prod validate')

# Step 8: Prod workspace, dev tfvars: the precondition stops it
run('terraform -chdir=ws workspace select prod')
run('terraform -chdir=ws plan -var-file=dev.tfvars', expect_error=True)   # EXPECTED TO FAIL: the video shows this error

# Step 9: A prod plan file in the dev workspace
run('terraform -chdir=ws plan -var-file=prod.tfvars -out=prod.tfplan')
run('terraform -chdir=ws workspace select dev')
run('terraform -chdir=ws apply prod.tfplan', expect_error=True)   # EXPECTED TO FAIL: the video shows this error

# Step 10: The network stack publishes outputs
run('terraform -chdir=stacks/network output')

# Step 11: The lake stack reads them with terraform_remote_state
run('terraform -chdir=stacks/lake plan')

# Step 12: Apply both environments (not shown)
run('terraform -chdir=envs/dev apply -auto-approve > /dev/null')
run('terraform -chdir=envs/prod apply -auto-approve > /dev/null')

# Step 13: Only the tfvars differ
run('diff envs/dev/main.tf envs/prod/main.tf && echo "main.tf: identical"', allow_error=True)
run('diff envs/dev/variables.tf envs/prod/variables.tf && echo "variables.tf: identical"', allow_error=True)
run('diff envs/dev/terraform.tfvars envs/prod/terraform.tfvars', allow_error=True)

# Step 14: A module change reaches every environment
write('modules/lake/main.tf', """\
locals {
  suffix = "${var.environment}${var.region.short}"
}

# stand-ins for azurerm_storage_account, its containers and a subnet
resource "terraform_data" "account" {
  input = {
    name        = "stsalesdata${local.suffix}"
    location    = var.region.location
    replication = var.replication
    tags        = var.tags
  }
}

resource "terraform_data" "container" {
  for_each = toset(["raw", "curated", "sandbox"])
  input    = "${terraform_data.account.input.name}/${each.key}"
}

resource "terraform_data" "subnet" {
  input = {
    name = "snet-lake-${local.suffix}"
    cidr = var.region.cidr
  }
}
""")
run('terraform -chdir=envs/dev plan -detailed-exitcode -refresh=false; echo "exit code: $?"')
run('terraform -chdir=envs/prod plan -detailed-exitcode -refresh=false; echo "exit code: $?"')

# Step 15: A new region in prod: one map entry
write('modules/lake/main.tf', """\
locals {
  suffix = "${var.environment}${var.region.short}"
}

# stand-ins for azurerm_storage_account, its containers and a subnet
resource "terraform_data" "account" {
  input = {
    name        = "stsalesdata${local.suffix}"
    location    = var.region.location
    replication = var.replication
    tags        = var.tags
  }
}

resource "terraform_data" "container" {
  for_each = toset(["raw", "curated"])
  input    = "${terraform_data.account.input.name}/${each.key}"
}

resource "terraform_data" "subnet" {
  input = {
    name = "snet-lake-${local.suffix}"
    cidr = var.region.cidr
  }
}
""")
write('envs/prod/terraform.tfvars', """\
environment   = "prod"
replication   = "GRS"
address_space = "10.40.0.0/16"
regions = {
  eastus2    = { short = "eus2", index = 0 }
  westeurope = { short = "weu", index = 1 }
}
""")
run('terraform -chdir=envs/prod plan -refresh=false')
Next in this series

Part 7: Refactoring, Import and Lifecycle

The sample data on this page is invented for teaching. Every terminal output is the real output of the command above it, run with Terraform 1.17.0; IDs, timestamps and durations are masked. Azure and Databricks code is checked with terraform validate. Part of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of October 2026; check the official Terraform documentation (developer.hashicorp.com/terraform) for anything you quote in an interview.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.