The Function App Module Is a Diff, Not a Fork
Reading the Function App module against its App Service sibling: the shared secure-hosting chassis, the runtime storage account and version knobs that are genuinely new, and what half-populated prod values teach about templates.
The fastest way to understand a module library is to stop reading modules and start reading diffs between modules. The Function App template in the library I worked on is nearly line-for-line the App Service template — same file layout, same locals bands, same provider pair — and that similarity is not laziness, it's information. Everything the two share is platform policy. Everything that differs is what Azure Functions actually adds to the hosting problem.
This is part 2 of the Terraform Module Catalog, the companion to the main Terraform on Azure series; the workspace-map and platform-JSON chassis both templates ride on is covered in main-series Part 2, and the App Service side of this diff is catalog Part 1.
What the diff keeps
Hold the two locals.tf files side by side and the top half is identical: spoke VNet and resource group from the platform JSON, private-link subnet for inbound, a delegated VNet-integration subnet for outbound (with the same comment warning you to pick the right one of several), the platform Key Vault, the app resource group, and the diagnostics storage account resolved through the same one-purpose data.tf data source. The main.tf passes the same aliased azurerm.shared provider so the module can reach hub-subscription resources.
In other words: a function app in this estate gets the full secure posture from Part 1 — private endpoint in, VNet integration out, Key Vault by reference, diagnostics to platform storage — without a single new decision. The posture is inherited, not re-implemented. When people ask what a golden-path library buys you, this is the answer: the second service type costs a diff, not a design review.
One quiet simplification: where App Service wired up two managed identities plus an optional user-assigned list, the Function App template passes a single identity name. Same Key Vault story, smaller surface.
What the diff adds
The genuinely function-shaped inputs are a short list:
locals {
# Functions need a storage account to exist at all
storage_account_name_list = {
dev = try(local.json_data.dev_spoke_devops_storage_account_name, null)
staging = try(local.json_data.staging_spoke_devops_storage_account_name, null)
prod = try(local.json_data.prod_spoke_devops_storage_account_name, null)
}
function_app_version_list = { # the runtime major version
dev = "~4", staging = "~4", prod = "~4"
}
function_runtime_list = { # FUNCTIONS_WORKER_RUNTIME
dev = "dotnet", staging = "dotnet", prod = "dotnet"
}
os_type_list = { # null = Windows, "linux" = Linux
dev = "linux", staging = "linux", prod = "linux"
}
}
Plus function_app_application_settings (the same map-of-strings idea as app_settings, renamed) and — the one that deserves a hard look — storage_account_primary_access_key.
Before the hard look, notice what the version and OS knobs being workspace maps buys you. The template shipped pinning the Functions host at ~3; the deployed stacks I studied had moved to ~4 — and because the version is a per-environment map, that migration can be staged: bump dev, soak, bump qa, and prod's row doesn't move until you edit it. A runtime major-version rollout becomes a sequence of one-line, one-environment pull requests with the whole rollout state visible in a single file. The same goes for os_type: the estate ran these functions on Linux, declared per environment, so even a hosting-OS experiment has a reviewable blast radius. It's the chassis idiom from the main series doing exactly what it's for.
Azure Functions requires a storage account for triggers, timers and keys, and the classic azurerm_function_app resource wants both the account name and an access key. The template models the key exactly like every other input: a workspace-keyed map of literals, with "TBC" in dev and empty strings below. Think about what that input name is inviting: a storage access key, pasted as plaintext into a lookup map, in a Git repository, once per environment. The estate's deployed stacks dodged it in an accidental way — several filled the key slot with the storage account name pulled from the platform JSON, which happens to be harmless if the underlying module fetches the real key itself, but it means the interface was lying: an input named primary_access_key that works when you hand it a name is an input nobody understands anymore. The right fix is for the module to look the key up from the account name internally (or better, use identity-based AzureWebJobsStorage) and delete the input. Interfaces that invite secrets eventually receive them.
The template also defaults the runtime storage to the spoke's shared DevOps storage account — the same account that receives diagnostics. It works, and it means a team can deploy a function without provisioning storage first; it also means runtime state, host keys and log archives all cohabit one account with one blast radius. The storage wrappers in Part 4 exist precisely so teams can do better than the default.
Where the estate diverged from the template
The pipeline side is the standard chassis — manual plan/apply, pinned module tags — with one nice archaeological detail: the Function App template pinned its variables repository at a tag missing the release- prefix its siblings used. Harmless, if both tags exist; but tag-naming drift in the templates is how you end up unable to script a fleet-wide version bump later.
The deployed stacks are where the honest lessons live, and two are worth carrying around.
Half-populated prod, waiting. More than one production stack in the estate carried maps like this:
function_app_name_list = {
dev = "func-orders-dev"
qa = "func-orders-qa"
staging = "func-orders-staging"
prod = "" # <- filled in "later"
}
The team had six environments live up to staging and had simply never done the prod run. Because the chassis uses forgiving lookup(..., null) and the empty string is even more forgiving than null, nothing complained: the file looked complete, the pipeline was green in five environments, and the failure was parked exactly where it would hurt most — the first production deploy, likely during a release window, in the environment with approvals and adrenaline. An empty string is a time bomb dressed as a value. A precondition asserting length(local.function_app_name) > 0, or strict map indexing plus a validation block, converts that into a plan-time paper cut in dev.
The typo that shipped six times. One stack's tag map spelled a tag value wrong — think "Experiance" for "Experience" — identically, in all six environments. Of course it did: nobody types a tag map six times, they write the dev block and copy it down. Copy-paste means an error in the master row replicates perfectly, and tags are exactly the values nobody proofreads because they don't break anything — they just quietly poison every cost report, inventory query and chargeback that later groups by that tag. Tags are data with downstream consumers; they deserve validation blocks (or at least a contains() check against an allowed list) as much as any SKU.
Both defects share a root cause: the chassis makes structure reviewable but treats values as opaque. The next module in the catalog is the smallest in the library, and it's the one that pushed back — the only template that chose to fail loudly. That's Part 3: the App Service Plan module.