Skip to content
Kumar Chandrachooda
Azure & DevOps

One JSON to Rule Every Environment

How workspace-keyed lookup maps and a single platform-facts JSON file let hundreds of Terraform stacks run identically in dev, staging and prod - and when a forgiving lookup default is the wrong kind of kindness. Part 2 of the Terraform on Azure series.

By Kumar Chandrachooda 23 Jul 2025 6 min read
One source of truth, two serializations, six environments

Every Terraform estate eventually has to answer the same question: where do the differences between environments live? Get it wrong and you end up with dev.tfvars and prod.tfvars files that drifted apart two years ago, or worse, a main.tf full of count = var.is_prod ? 1 : 0 conditionals that nobody can read.

In Part 1 I sketched the five-file stack layout used across an estate I worked on — hundreds of deployed stacks built from one template library. This part goes deep on the pattern that made those stacks portable across up to six environments without a single tfvars file: the workspace-keyed lookup map, fed by one JSON file of platform facts.

The workspace is a selector, not a state trick

Terraform workspaces have a mixed reputation, mostly because people use them as a state-isolation mechanism and then discover the isolation is thinner than they hoped. This estate used them differently. State isolation came from somewhere else entirely (per-environment storage accounts — that's Part 3). The workspace had exactly one job:

locals {
  env = terraform.workspace   # dev / staging / prod
}

That single line turns the workspace into an in-code environment selector. Everything else derives from local.env:

locals {
  app_name_list = {
    dev     = "app-orders-dev"
    staging = "app-orders-staging"
    prod    = "app-orders-prod"
  }
  app_name = lookup(local.app_name_list, local.env, null)

  sku_list = {
    dev     = "S1"
    staging = "P1v2"
    prod    = "P2v2"
  }
  sku = lookup(local.sku_list, local.env, null)
}

The pipeline runs terraform workspace select prod || terraform workspace new prod, and the same code resolves every knob for production. No file swapping, no -var-file juggling, no branch-per-environment horror.

Why maps beat tfvars at review time

The strongest argument for this idiom only shows up in pull requests. With per-environment tfvars files, a change to production is a diff in a file most reviewers have stopped reading, and its staging twin lives forty lines away in a different file — you cannot see that prod got the change and staging did not.

With lookup maps, the environments sit next to each other:

capacity_list = {
  dev     = 1
  staging = 2
  prod    = 4      # <- this PR changes exactly one visible line
}

A reviewer sees the whole environmental story of a value in four lines. Asymmetries jump out. In an estate with hundreds of stacks and a small platform team, that glanceability was worth more than any amount of tooling.

The cost is verbosity — every input becomes five lines instead of one — and a mild sense that you are fighting the language, because this pattern predates optional() object attributes and rich variable validation. Greenfield today I would still reach for it, but I would pair it with validation blocks; more on that below.

Strict or forgiving? Choose per input, on purpose

There is a subtle fork in the idiom, and the estate contained both branches. The forgiving form:

app_name = lookup(local.app_name_list, local.env, null)

If someone spins up a new qa workspace and forgets to add a qa key, app_name quietly becomes null and flows downstream — sometimes into a provider error three modules deep, sometimes into a resource that gets created with a garbage default. The failure is real but the diagnosis is miserable.

The strict form omits the default:

app_name = local.app_name_list[local.env]

Now a missing key fails immediately, loudly, at plan time, with the map name in the error. One of the sixteen template stacks in the library did this deliberately — its README even said “premium is required in nearly all situations, and if your environment is not listed, we want you to stop.”

Having watched both forms in production, my rule now: forgiving lookups for genuinely optional values, strict indexing for everything a resource cannot exist without. The estate's worst latent bugs were half-populated environments — a prod key left as "" by the template, waiting for the worst possible moment to matter. A strict lookup converts that time bomb into a plan-time paper cut. If I were writing the library today I would go further and encode it:

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be one of dev, staging, prod."
  }
}

Platform facts are not your facts

The second half of the pattern is about whose values these are. An app stack knows its own name, SKU and app settings. It does not — and should not — know the platform's network topology: which spoke VNet it deploys into, which subnet is delegated for private endpoints, which storage account receives diagnostics, which subscription is the hub.

Those facts belong to the platform team, and in this estate they lived in a separate repository (“PlatformVariables”) as a JSON file per platform region, checked out by the pipeline at a pinned tag and handed to Terraform as a file path:

variable "platform_vars_json" {
  type        = string
  description = "Path to the platform facts JSON, injected by the pipeline."
}

locals {
  json_data = jsondecode(file(var.platform_vars_json))

  vnet_name        = try(local.json_data.dev_spoke_vnet_name, null)
  pe_subnet_name   = try(local.json_data.dev_private_link_subnet, null)
  diag_storage_id  = try(local.json_data.diagnostics_storage_id, null)
}

Note the try() — the null-safe read is the right call here, because platform JSON evolves on its own schedule and an app stack should not explode because the platform team added a key it does not consume. Strictness for your own maps, tolerance for someone else's schema.

The division of labour this creates is the quiet win of the whole design. App teams never file tickets asking for subnet IDs. Platform teams never review app SKU changes. A stack's pull request touches exactly the values its team owns.

One source, two serializations

Here is the trick that took me longest to appreciate. The platform facts existed twice: once as a YAML variable template that the Azure DevOps pipeline includes, and once as the JSON twin passed into Terraform as TF_VAR_platform_vars_json.

# pipeline consumes the YAML face
variables:
  - template: platform-vars/region1.yaml@PlatformVariables
# Terraform consumes the JSON face
json_data = jsondecode(file(var.platform_vars_json))

At first glance that looks like duplication — the classic sin. But both files ship in the same repository, at the same pinned tag, generated from the same source. The effect is that the pipeline layer (which needs facts like the state storage account name at init time, before Terraform is even running) and the HCL layer (which needs facts like subnet names at plan time) can never disagree about the topology, because they are reading the same release of the same truth — each in the serialization its runtime natively speaks.

Most teams solve this with one file and a shim that converts at runtime. Two committed serializations of one source is less clever and more reliable, and at 2 a.m. I will take reliable.

What I'd do differently

Honest ledger time. The pattern scales beautifully in review and portability, but:

  • The maps invite copy-paste rot. Because every stack repeats the idiom, a typo in the template ("Lifecycel" in a tag map, say) propagates into dozens of stacks before anyone notices. Idioms that live in 200 copies need linting, not discipline.
  • null defaults hid real gaps. More than one stack had an environment key silently missing, masked by a forgiving lookup — discovered only when someone finally ran that environment. Today: strict indexing plus precondition blocks.
  • The workspace still has to exist. workspace select || workspace new in the pipeline papers over it, but a human running locally can plan against the default workspace and get a map full of nulls. A tiny terraform.workspace != "default" precondition would have closed that door.

The environment story only works because the state story backs it up — every environment's state lives in physically separate storage, which is why the workspace can afford to be a mere selector. That's next: Part 3, Where Does the State Live?