Skip to content
Kumar Chandrachooda
Azure & DevOps

Private DNS: Anatomy of a Half-Finished Template

The catalog's cautionary tale: an empty variables.tf, no platform JSON, UPDATE placeholders for every value, one pvars file for four declared workspaces, a subscription ID hardcoded into the provider, and a pipeline with a leftover debug step - what shipping a half-finished golden template does to the two hundred copies made from it, and a checklist for done. Part 14 of the Terraform Module Catalog.

By Kumar Chandrachooda 07 May 2026 6 min read
A golden template, abandoned mid-stroke

Every module catalog has one. The template that was clearly started by someone who knew the house style, needed to exist by Friday, worked once in dev, and then — nothing. No second pass, no parity with its siblings, no cleanup. It ships anyway, because it is in the catalog, and being in the catalog is what makes something look finished.

This is part 14 of the Terraform Module Catalog, companion to the main Terraform on Azure series — and it is the part that series' finale points at when it talks about the half-finished Private DNS template. The module's job is modest: create CNAME and A records in a private DNS zone so spoke workloads resolve internal names. The template's condition is the interesting part. I am going to walk it honestly, defect by defect, because the anonymized pattern — from an estate I worked on — is one every platform team is quietly incubating somewhere.

The inventory of absence

Put this template next to any sibling from the catalog and the gaps are structural, not cosmetic.

The variables.tf is empty. Not minimal — empty. Every other template in the library declares platform_vars_json, the file-path variable through which the pipeline injects the platform team's facts. This one declares nothing, which means it consumes no platform JSON at all. There is no chassis here; the template has opted out of the library's central design and nobody wrote down why.

Every value is an UPDATE placeholder. With no platform facts to draw on, the locals are wall-to-wall fill-in-the-blanks:

locals {
  env = terraform.workspace

  shared_resource_group_name_list = {
    dev          = "UPDATE"
    staging      = ""
    staging-weur = ""
    prod         = ""
  }

  a_record_ip_list  = { dev = "UPDATE", staging = "", staging-weur = "", prod = "" }
  base_dns_list     = { dev = "UPDATE", staging = "", staging-weur = "", prod = "" }
  cname_list        = { dev = ["UPDATE", "UPDATE"], staging = [], staging-weur = [], prod = [] }

  shared_resource_group = lookup(local.shared_resource_group_name_list, local.env)
  a_record_ip           = lookup(local.a_record_ip_list, local.env)
  base_dns              = lookup(local.base_dns_list, local.env)
  cnames                = lookup(local.cname_list, local.env)
}

Note the placeholder quality against Part 11's ladder: UPDATE is a legal resource-group name and "" is silently accepted by half the surface. Nothing here is invalid by construction. A copy applied without editing does not fail loudly; it limps.

Four workspaces declared, one supported. The maps carry a fourth key — staging-weur, a region-suffixed staging variant that appears nowhere else in the library — so someone once needed a West-Europe staging zone and widened the maps for it. But the pipeline's environment parameter offers only dev, staging and prod, and the folder contains exactly one pvars file: dev. The pipeline composes its variables from tf-pipeline-<env>-pvars.yaml, so selecting staging or prod does not even fail at plan time — it fails at pipeline compile time, template-not-found. The README, unbothered, instructs users to update “the staging and prod versions” of files that do not exist. And staging-weur is reachable by no pipeline path at all: a phantom environment, encoded in every map, deployable never.

The provider hardcodes the hub. Where every sibling reaches the hub subscription through an aliased provider fed by platform JSON, this template's default provider pins a literal subscription GUID:

provider "azurerm" {
  version                    = "~> 2.35"    # its own private version pin, too
  use_msi                    = true
  skip_provider_registration = true
  features {}
  subscription_id            = "<a live hub subscription id, in a template>"
}

A real subscription identifier, committed into a sample meant to be copied — every copy silently targeting the hub regardless of which spoke's pipeline runs it, every copy needing the same manual surgery, and an internal identifier propagating into every consuming repository. This is Front Door's hardcoded WAF ID again, but promoted from one input to the provider itself.

The pipeline is the simplest in the library — for the wrong reasons. No variable-repo checkout (nothing to check out), no terraform validate step, and — my favourite archaeological find — a leftover inline Bash step whose entire body is a debugging ls of the working directory. Someone was troubleshooting checkout paths one afternoon, and their scratch work has been executing on every run since. The state key is built from a pipeline variable named after the A record, so the state file's identity is coupled to one DNS entry the stack happens to manage.

There is one accidental virtue: the lookups are strict — no null default — so an unmapped workspace fails loudly. The least finished template in the catalog is, ironically, the only networking one that got lookup strictness right, and I would bet it was luck rather than judgment: the forgiving default was simply one more thing nobody added. It also passes base_dns into the module twice under two names (base_dns and zone_name), an interface wart consumers must now reproduce forever.

What a half-finished template does at scale

If this were one team's stack, it would be a Tuesday. It is not — it is a golden template, and the catalog's whole mechanism, described across the main series, is copy-and-fill. That mechanism has no recall and no patching: a template's condition at copy time is inherited, permanently, by every copy. So each gap above compounds:

  • Every consumer re-solves the same puzzle, divergently. No platform JSON means every team invents its own way to source the hub facts — some hardcode more, some bolt on the chassis themselves, some copy a different template's plumbing halfway. The estate ends up with N private-DNS conventions instead of one, which is precisely the outcome a catalog exists to prevent.
  • The template's authority launders its gaps. A blank in a scrappy repo reads as unfinished; the same blank in the official catalog reads as intentional. Consumers assume the missing validate step is a considered choice and drop it from their own pipelines. Golden templates do not just ship code — they ship the standard of care.
  • Placeholders become production. The main series' finale makes the general case; here is the concrete one. UPDATE is a deployable string. An empty prod key behind a copied-in forgiving lookup is a deferred outage. Whatever a template leaves unfilled, some fraction of two hundred copies will apply unfilled.
  • The support cost lands on humans. Every gap generates the same first-week conversation with every adopting team — the least scalable transmission channel an infrastructure organization has.

The bitter economics: finishing this template properly was perhaps two days of one platform engineer's time. The alternative was two hundred teams each spending a half-day discovering the same seven surprises, plus the permanent entropy of their divergent fixes. Templates are the highest-leverage code a platform team owns, which means their defects are the highest-leverage defects.

A definition of done for catalog templates

The fix is not heroism; it is a checklist. Before a template enters — or remains in — a golden catalog:

  1. It plans clean in every environment it declares. Every workspace key in every map gets a smoke plan in CI. A phantom environment like staging-weur either becomes reachable or gets deleted.
  2. Every placeholder fails loudly. Invalid-by-construction where syntax allows (the 10.999.x.x trick); strict lookups and precondition checks where it does not. UPDATE and "" must never survive to an API call.
  3. Chassis parity or a documented waiver. Same variables, providers, pipeline steps as the siblings — and any deliberate deviation written down in the README, so absence can be distinguished from abandonment.
  4. One pvars file per offered environment, and the pipeline's environment list, the locals' map keys, and the pvars files agree with each other exactly.
  5. No literals that identify anything. Subscription IDs, resource IDs, hostnames — platform facts come from the platform source of truth, never the template body.
  6. No scaffolding residue. Debug steps, fossil comments, another module's README links: gone. git blame should not be the only documentation of intent.
  7. A stranger test. Someone who did not write it deploys from a fresh copy, following only the README. Every question they ask is a defect.

None of that is sophisticated. All of it was skipped here, and the skip was invisible precisely because the catalog's other fifteen entries created a presumption of completeness this one silently spent.

The catalog closes next with its most ambitious member — the module that turns API governance itself into for_each maps, policy XML trees and OpenAPI specs, and the retrospective on what fifteen modules add up to: Part 15, APIM API Provisioning.