Skip to content
Kumar Chandrachooda
Azure & DevOps

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.

By Kumar Chandrachooda 08 Aug 2025 5 min read
Same window, new bolt - the parts that changed and the parts that didn't

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.