Skip to content
Kumar Chandrachooda
Azure & DevOps

DNS Zone: One Module, Many Optional Shapes

The public DNS template exposes every record type - A, AAAA, CAA, CNAME, MX, PTR, SRV, TXT - as an optional object list on a single module, and its twelve-kilobyte README quietly becomes the reference manual. When one module with optional shapes beats fifteen micro-modules, and when it stops being true. Part 13 of the Terraform Module Catalog.

By Kumar Chandrachooda 02 May 2026 6 min read
One zone, eight record shapes, all optional

DNS is the rare Azure service with no network posture to design: a public zone has no private endpoints, no VNet integration, no managed identity dance, no diagnostics pipeline worth arguing about. Strip all that away and what remains is a pure module interface design problem: one zone resource, eight record types, each with its own required fields, each optional, each potentially appearing dozens of times. The DNS Zone template's answer is the cleanest statement of a pattern this catalog keeps circling: one module, many optional shapes.

This is part 13 of the Terraform Module Catalog, companion to the main Terraform on Azure series, where the chassis these templates share — workspace maps, platform JSON, pinned modules — is covered once so no part has to re-explain it.

The surface: everything optional, nothing clever

The template's required inputs fit on one hand: a zone name, a resource group from the platform JSON, and the standard tag map. Everything else is an optional object list defaulting to null:

ttl_list                        = { dev = null, staging = null, prod = null }  # module defaults to 300
a_records_ips_list              = { dev = null, staging = null, prod = null }
a_records_resource_ids_list     = { dev = null, staging = null, prod = null }
aaaa_records_ips_list           = { dev = null, staging = null, prod = null }
aaaa_records_resource_ids_list  = { dev = null, staging = null, prod = null }
caa_records_list                = { dev = null, staging = null, prod = null }
cname_records_aliases_list      = { dev = null, staging = null, prod = null }
cname_records_resource_ids_list = { dev = null, staging = null, prod = null }
mx_records_list                 = { dev = null, staging = null, prod = null }
ptr_records_list                = { dev = null, staging = null, prod = null }
srv_records_list                = { dev = null, staging = null, prod = null }
txt_records_list                = { dev = null, staging = null, prod = null }

Filling one in looks like this:

a_records_ips_list = {
  dev = [
    { name = "orders",  ips = ["203.0.113.10"] },
    { name = "quoting", ips = ["203.0.113.11", "203.0.113.12"] },
  ]
  staging = null
  prod    = null
}

mx_records_list = {
  dev = [
    { preference = 10, exchange = "mail1.example.com" },
    { preference = 20, exchange = "mail2.example.com" },
  ]
  staging = null
  prod    = null
}

Each record type carries exactly the fields its RFC demands — CAA wants flags/tag/value, SRV wants priority/weight/port/target, TXT wants a list of values — and the module fans each list into the corresponding azurerm_dns_*_record resources. A single zone-wide TTL input covers the common case; the module defaults it to 300 seconds when left null.

One genuinely thoughtful split hides in the A, AAAA and CNAME inputs: each comes in two shapes. The plain shape takes literal values (ips, fqdn). The second takes an Azure resource ID instead — which the provider turns into an alias record, a pointer that tracks the target resource. When a Public IP is torn down and recreated, an A-by-value record goes stale; an A-by-resource-ID record follows it. The template does not make you know the provider mechanics; it makes you choose between “I know the address” and “I know the resource”, which is the right abstraction level, and the same choice Front Door's CNAME prerequisite makes you wish every DNS consumer had made explicitly.

The README is the actual interface

Here is the uncomfortable fact this template handles better than any of its fifteen siblings: none of this surface is visible to tooling. The wrapper's only true Terraform variable is the platform JSON path — the twelve optional shapes live in locals.tf, so the auto-generated terraform-docs table at the bottom of the README says, with a straight face, no inputs. I wrote about that failure mode in the Application Gateway part; this template is the countermeasure.

Its README — at roughly twelve kilobytes, the longest document in the library — is a hand-written reference manual. For every record type: what it is, a link to the upstream provider resource docs, the exact required fields with their types, and a complete worked example showing the filled-in map. A user populating SRV records does not reverse-engineer object shapes from provider error messages; they copy the SRV example and edit values. The examples double as executable documentation — paste them into locals.tf and they plan.

The general lesson: when a module's interface is expressed in locals rather than variables — a deliberate chassis choice in this library, with real benefits for environment review — documentation stops being generated and becomes engineering. The DNS README is what “we accepted that trade honestly” looks like. The empty terraform-docs stub sitting below all that hand-written excellence is what the trade costs.

One module or fifteen micro-modules?

The design question this template answers is worth making explicit, because the industry default has been drifting the other way. The alternative decomposition is one micro-module per record type — dns-a-record, dns-mx-record, and so on — each tiny, each typed, each independently versioned. Why prefer one module with twelve optional shapes?

  • One state, one pipeline, one review surface. A zone and its records are a single administrative object in real operations; splitting them across modules invites split state and split ownership of something that changes as a unit.
  • Additive cost is near zero. Adding your first TXT record is filling a null with a two-line object — not adding a module block, wiring a zone reference, and re-plumbing the pipeline.
  • The nulls document the possibility space. A newcomer reading locals.tf sees every record type the platform supports, including the ten they are not using. Fifteen micro-modules document nothing until you already know their names.

And when does it stop being true? Three pressure points, all visible if you imagine this template at estate scale:

  1. Validation. This library predates optional() object attributes and rich type constraints, so every shape is effectively any. Misspell prefernce in an MX object and nothing complains until the provider explodes mid-plan with an error naming none of your files. Micro-modules with typed variables catch that at once; today, typed optional() objects on the single module recover most of that ground without the decomposition.
  2. Ownership. One module means one state lock and one blast radius. The moment three teams want to manage their own records in a shared zone, the single-stack model makes every team's change everyone's plan. That is when per-delegation decomposition (or separate record-only stacks) earns its overhead.
  3. Scale noise. A zone with four hundred records makes every plan a wall of unchanged resources. DNS changes are frequent and tiny; plans should be too.

For a spoke team's zone with a few dozen records and one owning team — this library's actual case — the single module is simply correct.

The fossils in the folder

The honest-defects section, and this template has a good pair — anonymized patterns from an estate I worked on.

First, the provider block that cannot work. The template ships the library-standard aliased shared provider pointing at the hub subscription — subscription_id = local.sub_shared_subscription_id — but this template's locals.tf never defines that local, because public DNS needs no hub access and the platform-JSON plumbing that would define it was trimmed. Terraform treats a reference to an undeclared local as a hard error: the template, exactly as shipped, could not pass the terraform validate step in its own pipeline. Every consumer's first act was necessarily to delete or repair a provider block the README never mentions. Nobody had smoke-planned the template after the trim.

Second, the fossil comment. This template's data.tf contains no data sources at all — only a lone comment about a Front Door data source not yet being supported by the provider, with a link to the upstream GitHub issue. It is a harmless line with a lot to say: these templates reproduce by copy, the copies carry vestigial DNA from their ancestors, and nothing in the pipeline — no lint, no review gate — ever asks whether a file still describes the module it lives in. Templates have ancestry; without deliberate pruning, they show it.

Both defects share a root cause: the template was edited down from a sibling and never re-verified as a whole. Which is the perfect setup for the next part — the module where editing-down stopped halfway and shipped anyway. Part 14, Private DNS: Anatomy of a Half-Finished Template is the catalog's cautionary tale.