Skip to content
Kumar Chandrachooda
Azure & DevOps

A Service Registry Made of Key Vault Secrets

The most-consumed custom module in an Azure Terraform estate wrote one Key Vault secret per service - a JSON record carrying a URL, a credential and a type - and quietly became the runtime service registry for scores of Function Apps and Logic App workflows. How it worked, what it got right, and where a registry with no registry starts to strain. Part 6 of the Terraform on Azure series.

By Kumar Chandrachooda 15 Oct 2025 7 min read
One vault, one record per service, one naming convention

How do a hundred small services find each other at runtime? The textbook answers are a service mesh, Consul, or at least App Configuration with a discovery layer. The estate this series describes answered with none of those. It answered with one Key Vault and a naming convention — and the machinery that maintained it was the most-consumed custom module in the entire platform, appearing in 49 stacks. Part 5 covered the glue that published services through the API gateway; this part covers the other half of the glue family: the modules that wrote every service into a registry made of secrets.

The record

The core module does three things: mints a credential, installs it on the Function App, and writes one Key Vault secret whose value is a JSON document. Fresh illustrative code — shapes real, names invented:

resource "random_password" "service_key" {
  length           = 56
  special          = true
  override_special = ".!_+="
}

resource "azurerm_key_vault_secret" "registry_record" {
  name         = "${var.service_name}-${var.environment}"     # e.g. orders-pricing-dev
  key_vault_id = data.azurerm_key_vault.registry.id
  value = jsonencode({
    URL  = "https://${var.function_app_name}.azurewebsites.net/api"
    key  = random_password.service_key.result
    type = "azfun"
  })
}

resource "null_resource" "install_service_key" {
  triggers = {
    record = azurerm_key_vault_secret.registry_record.id   # re-fires when the record changes
  }
  provisioner "local-exec" {
    command = <<-EOT
      az functionapp keys set -g ${var.resource_group_name} -n ${var.function_app_name} \
        --key-type functionKeys \
        --key-name ${var.service_name}-${var.environment}-servicekey \
        --key-value ${random_password.service_key.result} \
        --subscription ${var.subscription_id}
    EOT
  }
}

The secret's name is the service's logical identity: <service>-<environment>. Its value is a discovery record: where the service lives, the key that opens it, and what kind of thing it is. A consumer that needs the pricing service in dev reads one secret — orders-pricing-dev — from the registry vault and gets everything required to call it. No consumer knows or cares which resource group, subscription or region the function landed in; deploy-time facts stay out of runtime code entirely. The function key itself is minted by Terraform (the provider still has no resource for that, so az functionapp keys set does the imperative work — Part 9 dissects these shell-outs) and installed under a purpose-named key, <service>-<env>-servicekey, so a human reading the function's key list can see exactly which credential belongs to the discovery machinery.

Three small honesty notes from the real thing. The original built its JSON by hand-escaping quotes in an interpolated string rather than jsonencode() — it worked, but every added field was a bracket-counting exercise. The null_resource triggers on the secret's resource ID, which is smarter than Part 5's timestamp() hack — the key is only reinstalled when the record actually changes — but it also inverts the order you would want: the record is written first and the key installed after, so there is a window where the registry advertises a key the function does not yet accept. And the environment variable's name was misspelled — think host_enviornment — frozen at 49 call sites, the same interface-permanence lesson Part 5's misspelled module folder taught: past the second caller, even your typos are API.

Logic Apps, same registry

The sibling module gives Logic App (Standard) workflows the identical shape. There is no key to mint this time — workflow triggers are SAS-signed — so an external data source runs PowerShell that calls az rest against the workflow's listCallbackUrl management endpoint, splits the callback into a base URL and its sp/sv/sig SAS query (the same script Part 5's APIM glue used), and the record becomes:

{
  "URL":  "https://la-orders-rating-dev.azurewebsites.net/api/ProcessOrdersRate/...",
  "key":  "api-version=2018-11-01&sp=...&sv=...&sig=...",
  "type": "azlogicapp"
}

That type field is the quiet masterstroke. It is a discriminator: a consumer resolves a name, reads type, and knows the calling convention — azfun means send key as an x-functions-key header, azlogicapp means append key as the SAS query string. The registry is heterogeneous by design, and extensible: a third runtime is one new type value away, with zero changes to consumers that don't call it. Logic App stacks drove the module with for_each over their workflow list, registering each workflow as its own service — ProcessOrdersRate plus a workflow name per record — which is also why one Logic App occupies four sibling folders in the estate's layout (Part 8 walks that pattern).

The bulk loader

Not every secret is a discovery record. The third module in the vault family is the estate's generic loader — used by roughly 33 stacks and small enough to quote almost in full:

resource "azurerm_key_vault_secret" "this" {
  for_each     = var.kv_secrets          # map(string)
  name         = each.key
  value        = each.value
  key_vault_id = data.azurerm_key_vault.this.id
}

A for_each over a map(string): give it a map, get a vault full of secrets. Leverage per line of code, off the charts — and a temptation engine. Those map values have to come from somewhere, and the path of least resistance is a literal map in a version-controlled locals.tf. In an estate I worked on, the path of least resistance won more than once: third-party API keys for entire environments sitting in plain HCL, because the loader accepted them without complaint. The module is not the defect — ten honest lines — but a generic loader needs a guardrail story (sensitive variables, pipeline-injected values, a pre-commit scanner) before its thirty-third consumer, not after.

The module that says less than it does

One more, from the common tier, because it teaches two lessons in eleven lines. A module named, in effect, add-role-assignment-user-assigned-identity — a name promising generic RBAC plumbing — looks up a user-assigned identity by name and grants it exactly two roles, hardcoded: Azure Service Bus Data Sender and Data Receiver on a namespace. Lesson one: the name describes the mechanism, the body implements a policy; every reader must open main.tf to learn that “add role assignment” means “wire this identity to Service Bus”. Either generalize the body (role list as input) or specialize the name (grant-servicebus-send-receive). Lesson two sits in variables.tf, where the Service Bus namespace ID's description reads, verbatim in spirit, “the user assigned identity name which is used for the function” — copy-pasted from the variable above it. Wrong documentation is worse than none, and copy-paste is how descriptions rot.

A registry with no registry

Now the architectural question this article exists to ask: is a pile of Key Vault secrets actually a service registry, or a trick that resembles one?

What it genuinely delivers:

  • Zero new infrastructure. The vault, its RBAC, its private endpoint, its audit log all existed anyway. The entire registry is a naming convention plus a JSON schema — no cluster to run, no agent, no sidecar, nothing new to patch or pay for.
  • Runtime resolution with deploy-time decoupling. Consumers hold a logical name; producers can move subscriptions, regions or resource groups and only the record changes. In an estate with no terraform_remote_state anywhere, this and the naming convention were the integration layer.
  • Heterogeneity through the type field. Functions and Logic App workflows resolve identically, and the discriminator leaves the door open for anything else.
  • Credentials ride along. Resolution and authentication are one read — the consumer never handles credential distribution as a separate problem.

And where it strains, all of which the estate eventually felt:

  • No liveness. A real registry has TTLs, health checks, heartbeats. A secret says where a service should be, not whether it is up; a decommissioned service resolves forever until someone remembers to delete its record — and delete is a manual act nobody owns.
  • Rotation is coupled to Terraform applies. Keys change when pipelines run — on the APIM side of the glue, on every run, thanks to Part 5's timestamp() trigger. Consumers that cache records hold stale keys until their next fetch, and nothing pushes an invalidation. The registry has eventual consistency without the machinery that usually makes that word safe.
  • The record name is the naming convention. <service>-<env> works only while every producer and consumer computes the same string. Rename a service and the old record lingers, still resolving, now pointing at an orphan — discovery breaks silently, the failure mode naming-convention coupling always buys. That dependency is big enough to be the next article.
  • Secret sprawl and audit blur. Hundreds of discovery records share a vault with actual secrets. Read access to resolve one service is read access to the registry's every credential; the vault's audit log answers “who read a secret” but nobody is left who can say which reads were discovery and which were exfiltration-shaped.
  • An unversioned schema. {"URL","key","type"} is a contract with no version field and no compiler. Adding a field is safe; renaming one is a fleet-wide breaking change detectable only in production.

My verdict after years alongside it: this is a good pattern wearing a disreputable name. Below a few hundred services, a poor man's registry made of infrastructure you already run — with liveness and rotation strains you consciously accept — beats a rich man's registry you must operate. The mistake would be adopting it without noticing which strains you signed up for; the insight worth exporting is that 80% of service discovery is a durable, access-controlled, name-addressable record store, and you probably already have one.

Every record in this registry, every source path in Part 5, every cross-stack reference in the whole estate leans on the same load-bearing assumption: that everyone, everywhere, agrees what things are called. What that agreement really is — an API with no compiler — and what happens when it breaks, is Part 7, Naming Is Your API.