Stock Modules Give You Resources; Glue Modules Give You a Platform
Nine thin custom Terraform modules carried the entire platform-integration story of an Azure estate. This part dissects the APIM-publication half - key minting through local-exec, SAS-splitting through PowerShell, twin modules one hyphen apart, and a typo frozen into dozens of source strings. Part 5 of the Terraform on Azure series.
Ask what a Terraform module library contains and you will hear about the big ones — the App Service module with twenty inputs, the Application Gateway module with its object lists. But audit which modules actually define the platform, and in the estate this series describes the answer was nine small custom modules that no registry could have supplied. The biggest is under a hundred lines. The most-used one appeared in 49 stacks.
Here is the distinction that took me years to articulate: a stock resource module gives you a resource; a thin local “glue” module plugs that resource into your platform. Any competent module — public registry, vendor-provided, homegrown — will stand up a Function App with VNet integration and diagnostics. No public module will ever register that Function App with your central API Management instance, mint its function key, and write your service-discovery record into your Key Vault. That last mile is where a pile of resources becomes a platform, and it is always yours to build.
The nine cluster into two jobs: publish the service through the API gateway, and write it into the estate's service registry. This part takes the gateway half at full depth; the registry half — the single most interesting design in the estate — gets Part 6 to itself. All code below is fresh and illustrative: the shapes are real, the names are not.
Publishing a Function App: the whole dance
The workhorse (roughly 38 consuming stacks) takes a freshly deployed Function App and puts it behind the central APIM instance, key and all. Four moving parts, one of them a shell-out:
resource "random_password" "function_key" {
length = 56
special = true
override_special = ".!_+=" # only URL-safe specials
}
resource "null_resource" "install_function_key" {
triggers = {
always_run = timestamp() # re-runs on EVERY apply - see below
}
provisioner "local-exec" {
command = <<-EOT
az functionapp keys set -g ${var.function_resource_group} -n ${var.function_app_name} \
--key-type functionKeys --key-name ${var.apim_name}-functionKey \
--key-value ${random_password.function_key.result} \
--subscription ${var.function_subscription_id}
EOT
}
}
resource "azurerm_api_management_named_value" "function_key" {
name = var.function_app_name
display_name = "${var.function_app_name}-functionKey"
api_management_name = var.apim_name
resource_group_name = var.apim_resource_group
value = random_password.function_key.result
secret = true
depends_on = [null_resource.install_function_key]
}
resource "azurerm_api_management_backend" "function" {
name = var.function_app_name
api_management_name = var.apim_name
resource_group_name = var.apim_resource_group
protocol = "http"
url = "https://${var.function_app_name}.azurewebsites.net/api"
resource_id = "https://management.azure.com${var.function_app_id}"
credentials {
header = {
"x-functions-key" = "{{${azurerm_api_management_named_value.function_key.name}}}"
}
}
}
Read it bottom-up and the design is elegant. The backend forwards to the function's /api root and injects x-functions-key on every call, but the header's value is {{named-value}} — APIM resolves it at call time from a secret named value, so the key never appears in policy XML or portal views. The named value gets its value from random_password, because the azurerm provider has no resource that mints function keys — so Terraform generates the key itself and pushes it onto the function imperatively with az functionapp keys set, --subscription flag and all, since the function lives in a spoke subscription and APIM in the hub. Callers authenticate to APIM with subscription keys; the function key exists only inside this machinery. (One period detail: the original module predated azurerm_api_management_named_value and used the long-deprecated azurerm_api_management_property alias. Glue modules outlive provider vocabulary.)
Then there is that trigger. always_run = timestamp() forces the null_resource to re-provision on every single apply, which means the function key is regenerated and reinstalled on every pipeline run. That is a feature: key rotation for free, no stale credentials, no rotation runbook. It is also a hazard: no apply is ever a no-op, every plan is dirty, and a deploy that touches nothing but a tag still rotates a production credential. Worst case, the apply dies between the key install and the named-value update and the gateway is now presenting yesterday's key. Rotation deserves its own schedule, not a side effect of infrastructure changes — the full mechanics of these shell-outs are Part 9's subject.
Publishing a Logic App: PowerShell in the data path
Logic App (Standard) workflows are triggered through SAS-signed callback URLs, and the provider cannot fetch those either. The Logic App variant of the glue reaches for the external data source — PowerShell inside the plan:
data "external" "workflow_urls" {
program = ["pwsh", "${path.module}/Get-WorkflowUrls.ps1"]
query = {
subscriptionId = var.logic_app_subscription_id
resourceGroup = var.logic_app_resource_group
logicAppName = var.logic_app_name
workflowName = var.workflow_name
}
}
The script calls the management plane for the workflow's callback URL, then splits it into the two pieces APIM needs — a base path for the backend and the SAS query for the credential:
$q = ConvertFrom-Json ([Console]::In.ReadLine())
$uri = "https://management.azure.com/subscriptions/$($q.subscriptionId)" +
"/resourceGroups/$($q.resourceGroup)/providers/Microsoft.Web/sites/$($q.logicAppName)" +
"/hostruntime/runtime/webhooks/workflow/api/management/workflows/$($q.workflowName)" +
"/triggers/manual/listCallbackUrl?api-version=2018-11-01"
$cb = az rest --method post --uri $uri | ConvertFrom-Json
@{
baseUrl = $cb.basePath
sasQuery = "api-version=$($cb.queries.'api-version')&sp=$($cb.queries.sp)" +
"&sv=$($cb.queries.sv)&sig=$($cb.queries.sig)"
} | ConvertTo-Json
The SAS query becomes a secret named value called <logicapp>-<workflow>-sas; the backend points at the workflow's hostruntime path; the operation policy appends {{...-sas}} to the rewritten URL. Two touches worth stealing. First, the backend's description field carried instructions to the next human — “use <name>-sas as the auth token when writing the operation policy” — documentation embedded in the resource where the reader will actually be standing. Second, the module shipped its own aliased provider block (provider "azurerm" { alias = "APIM" }, empty, a proxy for the caller's hub-subscription provider) so its writes landed in the hub while its lookups hit the spoke. Provider blocks inside modules are frowned on today — configuration_aliases is the modern spelling — but this is what made cross-subscription glue possible at the time. Consumers drove the whole thing with for_each over their workflow list: one module call, one backend and one credential per workflow.
The twin one hyphen away
The estate also contained the same module's evil twin: add-logicapp-to-apim and — one extra hyphen — add-logic-app-to-apim. The twin is seven flat variables wrapping a single bare backend resource: no SAS handling, no named value, no PowerShell, the caller supplies the URL itself. Several of its declared variables are never referenced in its one resource; its descriptions are copy-pasted from the function module and describe the wrong things. It is, visibly, a copy that diverged instead of a refactor that converged — and both twins were alive, both consumed, and nobody was confident enough in the call graph to delete either. Copy-paste divergence in a stack wastes a reviewer's afternoon; at module level it silently forks your platform's behaviour, because two teams believe they are using “the Logic App APIM module” and are getting different contracts.
Interfaces are forever, including the typos
The smallest module in the gateway family wraps one resource and one trick:
resource "azurerm_api_management_api_operation_policy" "this" {
api_name = var.api_name
operation_id = var.operation_id
api_management_name = var.apim_name
resource_group_name = var.apim_resource_group
xml_content = replace(file(var.policy_file_path), "environment", var.environment_name)
}
That replace() is a one-line templating engine: policy XML lives on disk with the literal token environment in its backend URLs, and the module stamps the real environment name in at apply time. Cheap, greppable, effective — right up until a policy legitimately contains the word “environment” in a comment and gets silently mangled.
But the trick is not why this module earns its place in the record. Its folder name — and therefore its source path, and therefore a string in dozens of callers — misspells the word “operation”. Think add-opertation-policy-to-apim. Fixing it would mean a coordinated edit across every consuming stack, so nobody fixed it, every scaffold copied it, and every new stack faithfully typed the typo for years. An interface becomes permanent the moment it has a second caller. Rename ruthlessly while callers are few; after that the name is load-bearing whether it is spelled right or not. Keep this exhibit in mind — it comes back at the end of the series.
And one more, filed under “document your workarounds before they fossilize”: the module that provisioned APIM groups for AAD-based access was supposed to link each group to a directory group. The linkage — the external_id and type arguments — sat commented out above a note reading, in effect, temporary workaround, the AD link to APIM is broken (with a tenant ID helpfully parked in the comment). That “temporary” workaround was years old and running in production, which means every group it created was a plain custom group that someone populated by hand, and every reader of the module re-derived whether the workaround was still needed. A date and an owner on that comment would have cost ten seconds.
Flat interfaces: verbose, and right
Every glue module took flat scalar strings — names, resource groups, subscription IDs — no objects, and no outputs at all:
module "publish_to_apim" {
source = "../custom-tf-modules/add-function-app-to-apim"
function_app_name = local.function_app_name
function_resource_group = local.resource_group
function_subscription_id = local.function_subscription_id
function_app_id = module.function_app.function_app_id
apim_name = local.central_apim_name
apim_resource_group = local.central_apim_rg
providers = { azurerm = azurerm.APIM }
depends_on = [module.function_app]
}
Modern Terraform style says pass rich objects. Having lived with the flat style at scale, I will defend it for glue: every call site is greppable and self-explanatory; there is no shared type definition to version across nine modules and hundreds of callers; and when something breaks you read one flat block, not a nested object assembled three locals away. Glue modules sit at the boundary between teams, and boundaries want boring, explicit interfaces. The costs are just as concrete: seven strings per call site that must agree with reality by convention alone, and — because nothing has outputs — consumers who need to reference what the glue created fall back on reconstructing names by string convention, deepening the coupling the naming article will dissect.
The gateway glue is only half the platform. The other half of these nine modules answers a harder question — once a service is deployed, how does everything else find it? — with one Key Vault, one JSON schema, and a naming convention doing the work of a service registry. That machine is Part 6, A Service Registry Made of Key Vault Secrets.