Skip to content
Kumar Chandrachooda
Azure & DevOps

Anatomy of a Reusable Terraform Stack for Azure

The conventions that let one Terraform sample library serve hundreds of Azure deployments - a five-file layout, workspace-indexed environments, pinned golden modules and pipeline-first delivery. Part 1 of a series on Terraform for the Azure estate.

By Kumar Chandrachooda 21 Jul 2025 3 min read
Five files, one module, any environment

Infrastructure-as-code libraries fail in a predictable way: the first few stacks are lovingly hand-crafted, the next twenty are copy-pastes of the first few, and by stack fifty nobody remembers which copy is the good one.

This series distils the conventions from a Terraform library I worked on that avoided that fate — a per-service catalogue of Azure sample stacks (App Service, Functions, API Management, AKS, Cosmos DB, Event Hubs, Front Door, SQL, storage and more) that ended up consumed by hundreds of pipeline-driven deployments across regions and environments. Part 1 is the anatomy of a single reusable stack; later parts build up to the estate.

The five-file contract

Every service stack in the library has the same shape, so anyone can open any folder and know where things live:

app-service/
├── main.tf          # one module call - the stack's actual intent
├── variables.tf     # the stack's inputs
├── locals.tf        # environment maps + derived values
├── provider.tf      # backend + provider wiring
├── data.tf          # lookups into the platform (subnets, log storage...)
├── tf-pipeline.yaml            # the CI/CD definition
├── tf-pipeline-{env}-pvars.yaml# per-environment pipeline variables
└── README.md        # step-by-step usage + generated inputs table

main.tf stays almost empty on purpose — it calls a versioned golden module and passes locals in. The intelligence lives in locals.tf; the reusable engineering lives in the module.

Environments without tfvars: the workspace map

Rather than dev.tfvars/prod.tfvars files drifting apart, every environment-dependent value is a map keyed by Terraform workspace:

locals {
  env = terraform.workspace

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

terraform workspace select prod and the same code resolves every knob for production. The differences between environments sit next to each other in one file, which makes review trivial: a pull request that changes prod but not staging is visible as exactly one line.

Is this the pattern I'd choose greenfield today? Later in the series I'll weigh it honestly against per-env tfvars and newer language features — it predates optional() — but its glanceability at scale is still underrated.

Golden modules, pinned like packages

Stacks never contain resource definitions; they call a module from a dedicated module repository, checked out by the pipeline at a pinned release tag:

resources:
  repositories:
    - repository: InfraModules       # the golden module code
      ref: 'refs/tags/release-1.0.1'
    - repository: PlatformVariables  # externalised platform config
      ref: 'refs/tags/release-1.1'

That is the governance model in two lines: infrastructure code, module versions and platform configuration live in three separately versioned repositories, and a deployment is a recorded combination of tags. Upgrading a module is a deliberate, reviewable ref bump — not whatever main happened to contain on deploy day.

The third repository holds platform-level facts (network ranges, subscription ids, central Key Vaults) as JSON that stacks consume without owning:

locals {
  json_data = jsondecode(file(var.platform_vars_json))
  subnet_id = try(local.json_data.network.private_link_subnet_id, null)
}

App teams describe what they deploy; the platform team owns where it lands.

Pipeline-first, identity-first

Nothing here is designed to be run from a laptop. The pipeline installs Terraform, wires the azurerm backend from variables at init time, and runs plan/apply under a managed identity — the provider block is deliberately boring:

terraform {
  backend "azurerm" {}   # configured entirely at init time
}

provider "azurerm" {
  features {}
  use_msi = true
}

provider "azurerm" {
  alias           = "shared"     # reaches the hub subscription
  subscription_id = local.shared_subscription_id
  use_msi         = true
}

That second, aliased provider is the hub-and-spoke enabler — stacks can look up shared resources in a central subscription while deploying into their own — and it gets a full part of the series to itself.

Where the series goes

  1. Anatomy of a reusable stack — this post.
  2. One JSON to rule every environment — workspace maps in depth, and a fair fight against tfvars-per-env.
  3. Where does the state live? — per-spoke storage, workspaces, and blast radius.
  4. The operator console and the maturity ladder — two generations of pipeline, and promoting the exact plan you approved.
  5. Stock modules give you resources; glue modules give you a platform — the APIM publication glue.
  6. A service registry made of Key Vault secrets — the discovery half of the glue.
  7. Naming is your API — string-convention coupling with no remote state anywhere.
  8. Logic Apps, four folders at a time — integration as infrastructure.
  9. Terraform's escape hatches are load-bearinglocal-exec, external, and a post-apply ARM PATCH.
  10. SQL as a governed blueprint — multi-phase Managed Instance deploys and the Terraform-to-PowerShell handoff for the things HCL cannot express.
  11. Migrating live databases with Log Replay Service — the cutover minute.
  12. What two hundred copies teach you — the retrospective.

And when you want the per-module detail behind all of it, the companion Terraform Module Catalog series walks the sixteen golden templates one by one.

If your Terraform estate is still one heroic root module per environment, start with the five files. Everything else in this series stands on them.