Fixing noisy Terraform plans with Azure App Gateways

Fixing noisy Terraform plans with Azure App Gateways

Global Azure 2026 talk: AzAPI list handling and per-app configs for shared Application Gateways.

Fixing noisy Terraform plans with Azure App Gateways

Managing a shared Application Gateway in Terraform is messy.

Not because it’s hard to define, but because you can’t trust what Terraform shows you. Add a backend pool and the plan says it’s removing and re-adding half the gateway. Nothing actually breaks, but the plan looks like a production outage waiting to happen.

This post walks through a pattern that fixes that. It uses new list handling capability that I contributed to AzAPI to make plans readable, and a YAML-per-app structure to make it easier to define multiple applications as configuration.

This is the same approach I covered at Global Azure 2026.

TL;DR

The full solution is at kewalaka/shared-app-gateway. There’s a Jupyter notebook that walks through deployment, idempotency checks, and adding/removing apps. If you just want to see it working, start there.

Why share a gateway?

Application Gateways are often shared for one reason: cost. They’re expensive enough that running a single instance for multiple apps makes sense, and they scale well in most environments.

This particular model works quite well if you have a central team managing ingress.

Source of the problem - everything is a flat list

An Application Gateway doesn’t understand “applications.” It just has lists: backend address pools, listeners, routing rules, probes, frontend ports. Every app contributes entries to the same lists.

illustration of the per-list model for app gateway

Terraform doesn’t get to update “just one app.” It has to send the entire configuration every time.

The plan noise problem

HashiCorp acknowledge the issue with azurerm_application_gateway in their provider docs:

AzureRM provider documentation warning that Application
Gateway sub-resource properties are returned as Sets, causing
noisy plans when items are added or
removed

The message boils down to: “yes, the plan is noisy, trust us.” That’s not a great place to be when you’re reviewing changes that affect production traffic.

The root cause: lists are treated as sets, items are matched by hash not identity, and ordering differences create fake diffs.

Fixing the root cause

Matt White raised issue #995 on the AzAPI provider to address this. I contributed the implementation, which landed in AzAPI v2.9.0.

Instead of matching list items by position or hash, you tell the provider what uniquely identifies each item.

1
2
3
4
5
6
7
list_unique_id_property = {
  "properties.frontendIPConfigurations"       = "name"
  "properties.backendAddressPools"            = "name"
  "properties.backendHttpSettingsCollection"  = "name"
  "properties.frontendPorts"                  = "name"
  "properties.backendAddressPools.properties.backendAddresses" = "ipAddress"
}

Most sub-resource types use name as the key, because ARM sub-resources are uniquely identified by name. The nested backendAddresses list uses ipAddress instead, since this block doesn’t have a name property.

It’s also possible to use a comma-separated list if needed. In short, you need to find the properties that uniquely identify the items in the list.

With this change, Terraform can match list items, which means you can add and remove items and have the plan only show the changes being made.

Defining multiple applications

With readable plans sorted, how do you define the apps within the gateway?

The obvious starting point for environmental configuration is tfvars. Define maps for each sub-resource type, pass them to the module:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
backend_address_pools = {
  "webapp-dev-beap1" = {
    name  = "webapp-dev-beap1"
    fqdns = ["httpbin.org"]
  }
  "api-dev-beap1" = {
    name  = "api-dev-beap1"
    fqdns = ["api-dev.azurewebsites.net"]
  }
}

This works until you have more than a couple of apps. Then you end up with large tfvars files, unrelated apps mixed together, fiddly configuration and a greater likelihood of error.

What if you could separate each application so it got its own file?

A YAML approach

There’s various ways to accomplish this, here’s an example:

1
2
3
4
5
6
environments/
  dev/
    dev.terraform.tfvars   # shared: networking, SKU, tags
    webapp-dev.yaml
    api-dev.yaml
    myapp-dev.yaml

Adding an app means adding a file. Removing one means deleting a file.

Terraform loads all YAML files and flattens them into the structure the gateway expects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
app_gw_files = fileset(
  local.base_app_gw_data_dir, "**/*.yaml"
)

app_gw_data_map = {
  for f in local.app_gw_files :
  replace(split(".", f)[0], "/", "-") =>
    yamldecode(
      file("${local.base_app_gw_data_dir}/${f}")
    )
}

A simple HTTP app looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
backend_address_pools:
  beap1:
    name: webapp-dev-beap1
    fqdns: ["httpbin.org"]

backend_http_settings:
  behtst1:
    name: webapp-dev-behtst1
    port: 80
    protocol: "Http"
    pick_host_name_from_backend_address: true
    probe_name: webapp-dev-probe1

http_listener:
  httplstn1:
    name: webapp-dev-http1
    frontend_ip_configuration_name: feip1_public
    frontend_port_name: fep1_http
    protocol: "Http"
    host_name: "webapp.dev.example.com"

request_routing_rules:
  rqrt1:
    name: webapp-dev-rqrt1
    priority: 10
    rule_type: Basic
    http_listener_name: webapp-dev-http1
    backend_address_pool_name: webapp-dev-beap1
    backend_http_settings_name: webapp-dev-behtst1

health_probes:
  probe1:
    name: webapp-dev-probe1
    protocol: "Http"
    port: 80
    path: "/get"

Cross-references are just names. The translation layer resolves them to ARM IDs at plan time. A prod app with HTTPS and HTTP-to-HTTPS redirect follows the same structure, just with extra ssl_certificates and redirect_configurations sections.

The translation layer

locals.tf sits between the YAML files and the AzAPI module. It flattens all YAML files into shared lists, converts name references into ARM resource IDs, and shapes everything to match the ARM schema.

The pattern is the same for each sub-resource type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
backend_address_pools = flatten([
  for filekey, contents in local.app_gw_data_map : [
    for key, value in contents.backend_address_pools : {
      name = value.name
      properties = {
        backend_addresses = concat(
          [for fqdn in try(value.fqdns, []) :
            { fqdn = fqdn }],
          [for ip in try(value.ip_addresses, []) :
            { ip_address = ip }]
        )
      }
    }
  ] if try(contents.backend_address_pools, null) != null
])

The outer loop iterates over YAML files, the inner loop over entries within each file. Files that don’t define a given resource type get skipped by the guard clause, and each value is wrapped in a properties block to match the ARM schema AzAPI expects.

Name-to-ID conversion happens inline:

1
2
3
probe = try(value.probe_name, null) != null ? {
  id = "${local.agw_id}/probes/${value.probe_name}"
} : null

The ARM resource ID pattern is always {gatewayId}/{subResourceType}/{name}.

Rules for coexistence

Multiple YAML files feeding into the same gateway need a few ground rules.

Names must be globally unique. I use {app}-{env}-{type}{number}, e.g. webapp-dev-beap1, api-dev-beap1. Easy to trace back to the owning app.

Routing priorities must not overlap. Give each app a range: webapp gets 10-19, api gets 20-29. New apps get the next block.

SSL certificates and other shared resources should only be defined in one file. If two files define the same certificate name, the deployment fails.

Upstream: AVM modules

The shared module currently uses forked resource modules in my own repo:

These include the conversion to AzAPI, and have just recently been PR’ed upstream to AVM.

The AVM team has been moving resource modules to AzAPI as the preferred provider, so this fits the direction.

My understanding is the team is moving towards normalising the property names between Bicep and Terraform, which means some work is required to remap existing implementations, there is an UPGRADE.md in the repo with details which can assist you or your friendly AI.

If you intend to explore this in production, I recommend you either inner source these modules or wait until they have (hopefully!) landed upstream.

Try it yourself

In the meantime, the full solution is at kewalaka/shared-app-gateway. If you only try one thing, use the Jupyter notebook. It deploys the gateway and four apps, proves the plan is clean on re-run, then removes and re-adds an app to show that only the affected sub-resources change.

For the details on just the list handling fix, and a video illustrating the behaviour, see part 1.

You can also catch up on the recording of the livestream from here: https://www.youtube.com/watch?v=Iwk-19w0T5E&t=20830s.

This post is licensed under CC BY 4.0 by the author.