One Storage Module, Five Thin Wrappers
The storage catalog entry is really five templates over one base module - blob, file, queue, table and static website - and the least-used wrapper is where every copy-paste defect in the estate went to hide.
Azure Storage is one resource pretending to be five services. The same azurerm_storage_account carries blobs, file shares, queues, tables and static websites, each with its own sub-resources, its own private-endpoint subresource type, and its own DNS zone. The library I worked on made an interesting call here: instead of one storage template with five modes, it shipped five sibling templates — storage/blob, storage/file, storage/queue, storage/table, storage/website — all thin wrappers over the same base module, pinned at the same release tag. This part of the catalog is about what that composition buys, and about the wrapper nobody used, which quietly became a museum of every defect the copy-paste manufacturing process can produce.
This is part 4 of the Terraform Module Catalog, companion to the main Terraform on Azure series; the chassis is in main-series Part 2, and the strict-vs-forgiving lookup theme running through this article started in catalog Part 3.
The wrapper delta is two inputs
Diff any two wrappers and the difference is almost embarrassingly small. Each adds exactly (a) its sub-resource input and (b) its private-endpoint subresource default:
# blob wrapper
containers = ["orders-in", "orders-archive"]
private_endpoint_types = ["blob"]
# file wrapper
fileshares = ["reports"]
private_endpoint_types = ["file"]
# queue wrapper
queues = ["orders-inbound"]
private_endpoint_types = ["queue"]
# website wrapper
static_website = { index_document = "index.html" }
private_endpoint_types = ["blob", "web"] # site content + site endpoint
Everything else — account name, tier, replication type, tags, region — is identical, and so is the security spine. Every wrapper passes the platform Key Vault name and a customer-managed key name straight from the platform JSON (..._key_vault_cmk_key_name), so every storage account in the estate gets CMK encryption with a platform-owned key by default, not as a hardening afterthought. Every wrapper passes the private-link subnet and the hub-subscription provider alias, so the base module can build the private endpoint and register it in the central private DNS zones. A team choosing “queue storage” over “blob storage” changes two lines of intent and inherits an identical posture.
That's the case for thin wrappers: the base module owns the hard, security-relevant engineering once, and the wrappers are just pre-filled order forms. The case against is subtler, and the estate demonstrates it thoroughly: five near-identical folders is five copies to keep honest, and nothing in the process kept them honest.
Where the copies rotted
Line the five wrappers up and read only their differences. It's an archaeology dig.
The table wrapper's private endpoint points at queues. Its default is private_endpoint_types = ["queue"] — a fossil of the copy order. Someone built queue from file, then built table from queue, and updated every queue-ism except the one that matters most: a team accepting the defaults would get a table endpoint published over the queue private-link subresource, which is to say, broken name resolution to their actual service, discovered at runtime.
The table wrapper's main.tf isn't Terraform. This is my favourite defect in the entire estate and I promise it's real: the file contains the README's markdown, top to bottom — headings, numbered steps, the doc-generator comment block. Somewhere in the copy chain a paste landed in the wrong buffer, and main.tf became documentation. The stack cannot parse, which means it cannot plan, which means with certainty no one ever ran this wrapper — not the authors, not a consumer, not once, or the very first terraform validate would have screamed. The other four wrappers worked, teams used blob and file and queue, and the fifth sat in the catalog for years as a landmine with a green folder icon.
The READMEs link to the wrong modules. Four of the five wrappers' “sample location” links point at the Cosmos DB template (the ancestor everything was copied from); the table wrapper's points at blob. And the doc-generation hook at the bottom of each README — the block that should list inputs and outputs — renders “No input. No output.” because nobody ever ran the generator either.
Dev keys in prod slots. Four of the five wrappers resolve the private-link subnet like this:
private_link_subnet_list = {
dev = try(local.json_data.dev_spoke_vnet_subnet_privatelink_name, null)
staging = try(local.json_data.dev_spoke_vnet_subnet_privatelink_name, null) # dev!
prod = try(local.json_data.dev_spoke_vnet_subnet_privatelink_name, null) # dev!
}
The dev JSON key, copied into the staging and prod rows and never re-pointed. Only blob got it right. This is the nastiest of the four defects because it works in dev — the environment where all testing happens — and produces a subtly wrong network placement in exactly the environments nobody exercises until go-live. The deployed blob stack I studied had inherited-then-fixed variants of this, including one environment where a frustrated engineer had given up on JSON indirection entirely and pasted the literal subnet name into the map. Once trust in a template's plumbing is broken, people route around the plumbing.
The deployed stack carried two smaller specimens worth logging. Its replication type was LRS in all three rows — dev's cheapest-possible choice copied down into production, so the account holding production data had single-datacenter redundancy, not because anyone decided that but because nobody re-decided it. (Replication type is exactly the kind of input whose prod row should never equal its dev row without a comment explaining why.) And the folder contained both a README.md and a misspelled Reademe.MD twin — trivial, harmless, and a perfect one-glance indicator of how much review pressure these folders were under.
The lesson: defects pool downhill
None of these bugs is interesting alone. Together they draw a precise map: defect density is inversely proportional to traffic. Blob, the wrapper everyone used, was clean. Table, the wrapper nobody needed, accumulated a wrong endpoint type, a wrong README, and a main.tf made of markdown — and kept them, because a defect in an unused template generates no error reports. The catalog's implicit promise (“any folder here is a working starting point”) was false in a way no consumer could detect until they'd already committed to the copy.
Two process fixes would have caught all of it. First, the one from Part 1: CI that runs terraform init && validate across every template folder on every commit — the markdown main.tf and the dangling references die instantly. Second, stop hand-copying the wrappers at all: five folders that differ by two inputs are a for loop in whatever generates your scaffolding, or at minimum a checklist diff (diff blob/locals.tf table/locals.tf as a review step). When the delta between siblings is mechanical, its production should be mechanical too. Humans copying five folders will produce four correct ones — the estate ran the experiment.
Next, the template those wrapper READMEs accidentally pointed at all along — the ancestor itself: Cosmos DB, where the module call finally meets raw resources.