Application Gateway: When Every Input Is a List of Objects
Inside the module with the biggest surface in the catalog: listener, pool, settings and probe objects cross-referenced by name, Key Vault TLS through a user-assigned identity, a private-by-default frontend - and a deliberately invalid placeholder IP that turns an unfilled template into a loud failure. Part 11 of the Terraform Module Catalog.
Most Azure resources are a handful of scalars and a tag map. Application Gateway is not. A single azurerm_application_gateway resource contains a dozen repeated nested blocks — frontend ports, listeners, backend pools, HTTP settings, routing rules, probes, certificates — and they all reference each other by name. It is less a resource than a small relational database that happens to terminate TLS.
This is part 11 of the Terraform Module Catalog, the companion to the main Terraform on Azure series; the chassis conventions (workspace-keyed lookup maps, platform-facts JSON, pinned golden modules) are covered there and assumed here. The Application Gateway template earns its place as the catalog's heavyweight: its locals.tf is the largest in the library, because the module's answer to all that nested complexity is to make every configurable concept a list of objects.
The surface: five lists and their foreign keys
Strip away the chassis plumbing and the template's per-environment payload looks like this (fresh illustrative code, as always in this series):
frontend_port_settings_list = {
dev = [
{ name = "agw-platform-dev-https-private-port", port = 443 },
{ name = "agw-platform-dev-https-public-port", port = 444 },
]
staging = []
prod = []
}
appgw_http_listeners_list = {
dev = [
{
name = "agw-platform-dev-https-private-listener"
frontend_port_name = "agw-platform-dev-https-private-port"
protocol = "Https"
host_name = "orders.dev.platform.example.com"
ssl_certificate_name = "dev-platform-example-com-cert"
},
{
name = "agw-platform-dev-https-public-listener"
frontend_port_name = "agw-platform-dev-https-public-port"
protocol = "Https"
frontend_ip_conf = "agw-platform-dev-public-ip-config" # opt-in public
host_name = "orders-ext.dev.platform.example.com"
ssl_certificate_name = "dev-platform-example-com-cert"
},
]
staging = []
prod = []
}
appgw_backend_http_settings_list = {
dev = [{
name = "agw-platform-dev-backend-https-settings"
cookie_based_affinity = "Disabled"
path = "/"
port = 443
protocol = "Https"
request_timeout = 300
probe_name = "agw-platform-dev-orders-probe"
}]
staging = []
prod = []
}
appgw_backend_pools_list = {
dev = [{
name = "agw-platform-dev-orders-pool"
fqdns = ["app-orders-dev.azurewebsites.net"]
}]
staging = []
prod = []
}
appgw_routings_list = {
dev = [{
name = "agw-platform-dev-orders-private-rule"
rule_type = "Basic"
http_listener_name = "agw-platform-dev-https-private-listener"
backend_address_pool_name = "agw-platform-dev-orders-pool"
backend_http_settings_name = "agw-platform-dev-backend-https-settings"
}]
staging = []
prod = []
}
appgw_probes_list = {
dev = [{
name = "agw-platform-dev-orders-probe"
path = "/healthz"
protocol = "Https"
pick_host_name_from_backend_http_settings = "true"
match_status_code = ["200-399"]
}]
staging = []
prod = []
}
Read the cross-references: the routing rule names a listener, a pool and an HTTP settings object; the HTTP settings object names a probe; the listener names a frontend port and an SSL certificate. Names are the foreign keys. The module does not resolve or verify them — Azure does, at apply time. The design honestly mirrors the underlying resource: Application Gateway really is wired together by name internally, and a module that hid that behind flattened inputs would have to invent its own join logic and its own failure modes. Exposing the relational shape as-is is the less clever, more predictable choice.
The cost is that a user must keep one rename consistent across up to five lists. The mitigation the template chooses is worked example over abstraction: the dev environment ships fully populated with a coherent, cross-referenced example, so a new user edits a working graph instead of assembling one from prose.
Two smaller design notes hide in the listener objects. Health probes accept status-code ranges (["200-399"]) rather than a single code — the difference between a probe that survives a redirect-happy backend and one that flaps. And the public frontend is opt-in per listener: omit frontend_ip_conf and the listener binds to the private frontend IP; add it and that one listener — not the gateway — becomes publicly reachable. Private is the default posture; exposure is a deliberate, per-listener line in a pull request.
TLS: Key Vault, via a user-assigned identity
The gateway never sees a certificate file. The SSL configuration is another object list pointing at Key Vault secrets:
ssl_certificates_configs_list = {
dev = [{
name = "wildcard-platform-cert"
key_vault_name = try(local.json_data.dev_spoke_app_key_vault_name, null)
key_vault_cert_value = "dev-platform-example-com-cert"
key_vault_password_value = "dev-platform-example-com-cert-password"
}]
staging = []
prod = []
}
The identity that reads those secrets is a user-assigned managed identity whose name comes from the platform JSON (..._resource_group_mi_agw_name), pre-created and pre-granted by the platform team. That split is the chassis philosophy applied to TLS: the app team decides which certificate secures which listener; the platform team owns the vault, the identity and the access policy. Certificate rotation happens in Key Vault and never touches Terraform state.
The template also registers the gateway in DNS: a private DNS zone name and a list of A-records to create in it, so orders.dev.platform.example.com resolves to the gateway's private IP inside the VNet. Diagnostics complete the posture — a data lookup on the platform's diagnostics storage account, its ID passed as logs_destinations_ids.
The 999 trick: placeholder design as a safety feature
My favourite line in the entire library is the private IP placeholder:
appgw_private_ip_list = {
dev = "10.999.24.5" # deliberately not an IP address
staging = "TBD"
prod = "TBD"
}
An octet of 999 cannot exist. A user who copies the template and forgets to set the gateway's static private IP does not get a silently-working deployment on a random address — they get a hard, immediate failure naming exactly the value they forgot.
Compare the alternatives seen elsewhere in the catalog. A placeholder of "" often applies and creates a half-configured resource. A plausible-looking dummy like 10.0.0.4 is worse: it applies cleanly and squats on an address the network team allocated to something else. 10.999.24.5 is invalid by construction — it can never survive contact with the Azure API, so an unfilled template can never become deployed infrastructure.
That is a general principle worth stealing: a placeholder's job is to fail, loudly, as early as possible. For strings that Azure will accept anyway (names, paths), the failure has to be moved earlier by other means — strict lookup without a default, or precondition blocks in modern Terraform. For values with a syntax, make the placeholder violate the syntax. The template's own TBD strings in the staging and prod maps show the weaker form: some would fail (a region called TBD does not exist), but others would sail through as legal, meaningless names.
What terraform-docs cannot see
The DevOps wiring is the standard generation-1 pipeline from the main series — manual trigger, spoke and operation parameters, pinned module and variable repos, validate, then plan or apply. Nothing exotic. The honest defects in this template live elsewhere:
- The generated docs are empty. The README ends with a terraform-docs table that says, in effect, no inputs, no outputs — because the wrapper's only true variable is the platform JSON path, and the entire twenty-odd-input surface lives in
locals.tf, which terraform-docs cannot see. The richest module surface in the catalog is invisible to its own documentation tooling. If your module's interface is a locals file, your README must be hand-written — the App Service and DNS Zone templates do this well; this one leans entirely on the inline example. - Only dev is populated. Staging and prod ship as empty lists and
TBDscalars. That is defensible for a template — but combined with forgivinglookup(..., null)defaults, a copy promoted to staging without editing every list plans “successfully” toward a uselessly empty gateway. - The diagnostics lookups default to the string
"default"rather thannull— a third placeholder style in one file, which fails eventually (no storage account nameddefault) but far from the point of error.
One template, three placeholder philosophies: invalid-by-construction (10.999.x.x), inert (TBD, ""), and misleadingly plausible ("default"). Only the first one is doing its job.
The gateway guards the front door of a spoke. The next part walks the module that guards the front door of the whole estate — classic Front Door, WAF links, edge TLS, and two defects that teach more than the happy path does: Part 12, Front Door.