Skip to content
Kumar Chandrachooda
Azure & DevOps

The App Service Module: Twenty Inputs, One Security Posture

Opening the Terraform Module Catalog with the library's flagship: how an App Service module bundles VNet integration, dual managed identities and Key Vault TLS into a surface app teams can fill in but not opt out of.

By Kumar Chandrachooda 26 Jul 2025 5 min read
An app window wired into the network, with the key already cut

When an application team says “we need a web app in Azure”, they are asking for one resource. What the platform actually needs to exist is closer to ten: an app on the right plan, integrated into the right spoke subnet, reachable only through a private endpoint, running as a managed identity that can already read Key Vault, serving TLS from a certificate it never touches, and shipping diagnostics somewhere an auditor can find them. The App Service module was where the library I worked on compressed all of that into one module call — and it was the flagship: the most-copied template, and the only one whose README amounted to genuine reference documentation.

This is part 1 of the Terraform Module Catalog, a companion series to Terraform on Azure that walks that library one module per article. The chassis every stack shares — workspace-keyed lookup maps fed by a platform-facts JSON — is covered in Part 2 of the main series, so here I'll go straight to what this module adds on top.

Three bands of inputs

The module's surface is around twenty inputs, and the interesting thing is not the count but the banding. Every input falls into one of three bands, and the locals.tf layout makes the bands visible:

locals {
  # Band 1 - platform facts, read from JSON, never edited
  private_link_subnet_list = {
    dev     = try(local.json_data.dev_spoke_vnet_subnet_privatelink_name, null)
    staging = try(local.json_data.staging_spoke_vnet_subnet_privatelink_name, null)
    prod    = try(local.json_data.prod_spoke_vnet_subnet_privatelink_name, null)
  }

  # Band 2 - required app facts, filled in by the team
  app_service_name_list = {
    dev     = "app-orders-dev"
    staging = "app-orders-staging"
    prod    = "app-orders-prod"
  }

  # Band 3 - optional shapes, empty by default
  connection_strings_list = {
    dev     = [{ name = "Orders", type = "SQLServer", value = "..." }]
    staging = []
    prod    = []
  }
}

Band 1 covers the spoke VNet, its resource group, the private-link subnet, the VNet-integration subnet (a separate, delegated subnet — the template comments warn there are several and you must pick the one assigned to your app), the app resource group, the platform Key Vault, and two managed identity names. Band 2 is what only the team knows: plan name, app name, region, the four mandatory tags. Band 3 is where the azurerm shapes live: site_config (the README enumerates roughly twenty accepted keys, from always_on to websockets_enabled), app_settings, auth_settings for AAD login, typed connection_strings, client affinity and client certificate toggles, and the custom-domain pair I'll come back to.

That banding is the real interface documentation. A reviewer who knows the convention can tell at a glance whether a pull request is touching platform topology (suspicious), app facts (normal), or optional shapes (normal, read the diff).

Certificates the app never owns

The custom-domain story is my favourite detail of the surface. Two parallel inputs:

custom_certificates_list = {
  dev = ["cert-orders-public"]          # names of KV certificate secrets
}

custom_domains_list = {
  dev = [{
    domain_fqdn                = "orders.example.com"
    key_vault_certificate_name = "cert-orders-public"
  }]
}

The stack passes names, not certificates. The module resolves them against the platform Key Vault, and the identity doing the resolving is one of the two managed identities the module wires up: a platform-provided identity that ships with every spoke's app resource group and already holds Key Vault read rights, plus an AAD-read identity for directory lookups. Teams that don't want to share the spoke identity can pass a user_assigned_identity_names list instead, and the module creates identities and grants them the Key Vault access they need. Either way, no certificate bytes and no vault access policies ever appear in an app team's repository. TLS becomes a string-typed reference.

Diagnostics by data source

Every stack in the library ships logs, and this module established the pattern the others copied. The platform JSON provides a storage account name; the resource needs an ID; a one-purpose data.tf does the conversion:

data "azurerm_storage_account" "diagnostics" {
  name                = local.diag_settings_destination_name
  resource_group_name = local.diag_settings_destination_rg
}

# in main.tf
logs_destinations_ids = [data.azurerm_storage_account.diagnostics.id]

Right below it sits a commented-out azurerm_log_analytics_workspace twin. The input is a list on purpose — multiple destinations are legal — and switching to Log Analytics is a matter of uncommenting one block and swapping one reference. Configuration-by-uncommenting is crude, and I'll show in a later part where it goes wrong, but as a way of documenting “here is the other supported choice” inside a template, it worked better than any wiki page.

The posture, assembled

Step back and the architecture lens shows why this module earned flagship status. Inbound traffic reaches the app only through a private endpoint in the spoke's private-link subnet. Outbound traffic leaves through regional VNet integration in a delegated subnet, so the app's egress is subject to the spoke's routing and NSGs. The app runs as managed identities with pre-scoped Key Vault access. Custom-domain TLS comes from Key Vault by reference. Diagnostics land in the platform's storage account via an aliased provider that can reach the hub subscription. None of these is individually clever; the value is that a team cannot take the module and quietly skip one. The module doesn't offer a secure posture — it offers a posture, and secure is the only shape it comes in.

From the pipeline's point of view there is nothing special here, which is itself the point: the same manual plan/apply console as every other stack, with the module repository pinned at a release tag. The flagship earns no exemptions from the chassis.

The dangling local

Now the honest part. In an estate I worked on, this module's template shipped with a defect that survived every copy generation. One computed local referenced a lookup map that did not exist:

user_assigned_identity_names_list = {   # plural - this map exists
  dev = []
  ...
}

# plural map, resolved correctly
user_assigned_identity_names = lookup(local.user_assigned_identity_names_list, local.env, null)

# singular - this map exists nowhere in the file
user_assigned_identity_name  = lookup(local.user_assigned_identity_name_list, local.env, null)

A leftover from an earlier revision of the interface, singular renamed to plural, the old line never deleted. Terraform will not evaluate a reference to an undeclared local, so every fresh copy of this template failed terraform validate until someone touched it. And you can watch teams discover it independently: in deployed stacks the offending line reappears commented out, each copy carrying the same hand-applied bandage, because nobody with the keys to the template ever fixed the source.

The lesson generalises beyond one line. A template is code that runs in other people's repositories, and this estate had no CI that so much as validated the templates themselves — validation only happened downstream, inside each consumer's pipeline, after the copy. Dead code in a normal module gets cleaned up eventually; dead code in a scaffold is immortal, because every copy inherits it before anyone reads it. If your golden path is a folder people copy, the minimum bar is a pipeline that runs terraform init && terraform validate against every template on every commit. The flagship deserved at least that.

Next in the catalog: the Function App module, which is best read the way the estate's engineers actually read it — as a diff against this one.