Quick take — A production-ready Terraform module for Azure Application Gateway v2 (azurerm ~> 4.0): autoscaling WAF_v2 SKU, HTTPS listeners with Key Vault certs, health probes, and managed identity, all var-driven. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "application_gateway" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-application-gateway?ref=v1.0.0"
name = "..." # Gateway name; prefix for derived child config names (3-…
resource_group_name = "..." # Resource group for the gateway.
location = "..." # Azure region (e.g. centralindia).
subnet_id = "..." # Dedicated subnet ID (Application Gateway instances only…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Application Gateway is a regional, zone-redundant Layer 7 (HTTP/HTTPS) load balancer with a built-in Web Application Firewall. Unlike a Layer 4 load balancer, it terminates TLS, routes on host header and URL path, rewrites headers, and offloads SSL — and the WAF_v2 SKU adds OWASP Core Rule Set protection in front of every backend. The catch is that the azurerm_application_gateway resource is one of the most verbose in the entire provider: a single gateway stitches together gateway_ip_configuration, frontend_ip_configuration, frontend_port, backend_address_pool, backend_http_settings, http_listener, request_routing_rule, probe, ssl_certificate, and autoscale_configuration blocks — all of which reference each other by name. Get one name wrong and terraform apply fails after several minutes of provisioning.
This module wraps that complexity into a single, opinionated, var-driven block. It defaults to the WAF_v2 SKU with autoscaling and zone redundancy, pulls TLS certificates straight from Key Vault via a user-assigned managed identity (so private keys never touch state), wires up a sane HTTP-to-HTTPS redirect, and exposes the public IP, gateway ID, and backend pool name as outputs that downstream resources (DNS records, NSGs, private endpoints) can consume.
When to use it
- You need host- or path-based routing to multiple backends (e.g.
app.kloudvin.comandapi.kloudvin.combehind one public IP) rather than a simple round-robin L4 forward. - You want a managed WAF in front of App Service, AKS ingress, VMSS, or on-prem backends without standing up a separate firewall appliance.
- You need TLS termination with certificates rotated in Key Vault, where the gateway should pick up new cert versions automatically rather than storing PFX blobs in Terraform state.
- You are standardising ingress across many landing zones and want every gateway to be zone-redundant, autoscaled, and WAF-enabled by default with no per-team drift.
- Reach for Azure Front Door instead if you need global anycast, CDN, or multi-region failover — Application Gateway is regional. Use this module for the regional ingress tier underneath Front Door, or as standalone ingress for single-region workloads.
Module structure
terraform-module-azure-application-gateway/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Stable, derived names so listeners/rules/settings reference each other safely.
gateway_ip_config_name = "${var.name}-gwipcfg"
frontend_ip_config_name = "${var.name}-feip"
frontend_port_https_name = "port-443"
frontend_port_http_name = "port-80"
redirect_config_name = "${var.name}-http-to-https"
# Only attach a public frontend IP when a public IP id is supplied.
enable_public_frontend = var.public_ip_address_id != null
}
resource "azurerm_application_gateway" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
zones = var.zones
tags = var.tags
sku {
name = var.sku_name
tier = var.sku_tier
# capacity must be omitted when autoscale is used; provider rejects both.
capacity = var.autoscale == null ? var.capacity : null
}
dynamic "autoscale_configuration" {
for_each = var.autoscale == null ? [] : [var.autoscale]
content {
min_capacity = autoscale_configuration.value.min_capacity
max_capacity = autoscale_configuration.value.max_capacity
}
}
# User-assigned identity is required to read TLS certs from Key Vault.
dynamic "identity" {
for_each = length(var.identity_ids) == 0 ? [] : [1]
content {
type = "UserAssigned"
identity_ids = var.identity_ids
}
}
gateway_ip_configuration {
name = local.gateway_ip_config_name
subnet_id = var.subnet_id
}
frontend_ip_configuration {
name = local.frontend_ip_config_name
public_ip_address_id = local.enable_public_frontend ? var.public_ip_address_id : null
# Private frontend (internal gateway) when no public IP is provided.
subnet_id = local.enable_public_frontend ? null : var.subnet_id
private_ip_address = local.enable_public_frontend ? null : var.private_ip_address
private_ip_address_allocation = local.enable_public_frontend ? null : "Static"
}
frontend_port {
name = local.frontend_port_https_name
port = 443
}
frontend_port {
name = local.frontend_port_http_name
port = 80
}
# TLS certificates sourced from Key Vault by secret id (versionless id keeps rotation automatic).
dynamic "ssl_certificate" {
for_each = var.ssl_certificates
content {
name = ssl_certificate.value.name
key_vault_secret_id = ssl_certificate.value.key_vault_secret_id
}
}
dynamic "backend_address_pool" {
for_each = var.backend_pools
content {
name = backend_address_pool.value.name
fqdns = backend_address_pool.value.fqdns
ip_addresses = backend_address_pool.value.ip_addresses
}
}
dynamic "probe" {
for_each = var.health_probes
content {
name = probe.value.name
protocol = probe.value.protocol
path = probe.value.path
host = probe.value.host
pick_host_name_from_backend_http_settings = probe.value.host == null
interval = probe.value.interval
timeout = probe.value.timeout
unhealthy_threshold = probe.value.unhealthy_threshold
match {
status_code = probe.value.status_codes
}
}
}
dynamic "backend_http_settings" {
for_each = var.backend_http_settings
content {
name = backend_http_settings.value.name
cookie_based_affinity = backend_http_settings.value.cookie_based_affinity
port = backend_http_settings.value.port
protocol = backend_http_settings.value.protocol
request_timeout = backend_http_settings.value.request_timeout
probe_name = backend_http_settings.value.probe_name
pick_host_name_from_backend_address = backend_http_settings.value.pick_host_name_from_backend_address
}
}
dynamic "http_listener" {
for_each = var.https_listeners
content {
name = http_listener.value.name
frontend_ip_configuration_name = local.frontend_ip_config_name
frontend_port_name = local.frontend_port_https_name
protocol = "Https"
host_name = http_listener.value.host_name
ssl_certificate_name = http_listener.value.ssl_certificate_name
}
}
# Single shared HTTP listener used only to drive the HTTPS redirect.
http_listener {
name = "${var.name}-http"
frontend_ip_configuration_name = local.frontend_ip_config_name
frontend_port_name = local.frontend_port_http_name
protocol = "Http"
}
redirect_configuration {
name = local.redirect_config_name
redirect_type = "Permanent"
target_listener_name = var.https_listeners[0].name
include_path = true
include_query_string = true
}
request_routing_rule {
name = "${var.name}-http-redirect"
rule_type = "Basic"
http_listener_name = "${var.name}-http"
redirect_configuration_name = local.redirect_config_name
priority = 100
}
dynamic "request_routing_rule" {
for_each = var.routing_rules
content {
name = request_routing_rule.value.name
rule_type = "Basic"
http_listener_name = request_routing_rule.value.listener_name
backend_address_pool_name = request_routing_rule.value.backend_pool_name
backend_http_settings_name = request_routing_rule.value.backend_http_settings_name
priority = request_routing_rule.value.priority
}
}
dynamic "waf_configuration" {
for_each = var.sku_tier == "WAF_v2" && var.waf == null ? [] : (var.waf == null ? [] : [var.waf])
content {
enabled = waf_configuration.value.enabled
firewall_mode = waf_configuration.value.firewall_mode
rule_set_type = "OWASP"
rule_set_version = waf_configuration.value.rule_set_version
file_upload_limit_mb = waf_configuration.value.file_upload_limit_mb
max_request_body_size_kb = waf_configuration.value.max_request_body_size_kb
}
}
lifecycle {
# AKS AGIC and other controllers mutate pools/listeners at runtime;
# ignore those so Terraform does not fight the ingress controller.
ignore_changes = [
backend_address_pool,
backend_http_settings,
http_listener,
request_routing_rule,
probe,
tags["managed-by-k8s-ingress"],
]
}
}
variables.tf
variable "name" {
type = string
description = "Name of the Application Gateway. Used as a prefix for derived child config names."
validation {
condition = can(regex("^[a-zA-Z][a-zA-Z0-9-]{1,78}[a-zA-Z0-9]$", var.name))
error_message = "name must be 3-80 chars, start with a letter, end alphanumeric, and contain only letters, numbers and hyphens."
}
}
variable "resource_group_name" {
type = string
description = "Resource group in which to create the Application Gateway."
}
variable "location" {
type = string
description = "Azure region (e.g. centralindia, eastus)."
}
variable "subnet_id" {
type = string
description = "ID of a dedicated subnet for the gateway. Must contain ONLY Application Gateway instances (min /24 recommended for v2 autoscale)."
}
variable "public_ip_address_id" {
type = string
description = "ID of a Standard SKU, Static public IP for the frontend. Set to null to create an internal (private-frontend) gateway."
default = null
}
variable "private_ip_address" {
type = string
description = "Static private IP for an internal gateway. Required when public_ip_address_id is null; must fall inside subnet_id's range."
default = null
}
variable "sku_name" {
type = string
description = "Gateway SKU name."
default = "WAF_v2"
validation {
condition = contains(["Standard_v2", "WAF_v2"], var.sku_name)
error_message = "sku_name must be Standard_v2 or WAF_v2 (v1 SKUs are retired)."
}
}
variable "sku_tier" {
type = string
description = "Gateway SKU tier. Should match sku_name."
default = "WAF_v2"
validation {
condition = contains(["Standard_v2", "WAF_v2"], var.sku_tier)
error_message = "sku_tier must be Standard_v2 or WAF_v2."
}
}
variable "capacity" {
type = number
description = "Fixed instance count. Used ONLY when autoscale is null."
default = 2
validation {
condition = var.capacity >= 1 && var.capacity <= 125
error_message = "capacity must be between 1 and 125."
}
}
variable "autoscale" {
type = object({
min_capacity = number
max_capacity = number
})
description = "Autoscale bounds for v2 SKUs. When set, fixed capacity is ignored. Set to null to use a fixed capacity instead."
default = {
min_capacity = 2
max_capacity = 10
}
validation {
condition = var.autoscale == null ? true : (
var.autoscale.min_capacity >= 0 &&
var.autoscale.max_capacity <= 125 &&
var.autoscale.max_capacity >= var.autoscale.min_capacity
)
error_message = "autoscale min_capacity must be >= 0, max_capacity <= 125, and max >= min."
}
}
variable "zones" {
type = list(string)
description = "Availability zones to spread instances across. Empty list disables zone redundancy."
default = ["1", "2", "3"]
}
variable "identity_ids" {
type = list(string)
description = "User-assigned managed identity IDs. Required to read TLS certificates from Key Vault."
default = []
}
variable "ssl_certificates" {
type = list(object({
name = string
key_vault_secret_id = string
}))
description = "TLS certs to import from Key Vault. Use the VERSIONLESS secret id so the gateway auto-rotates."
default = []
}
variable "backend_pools" {
type = list(object({
name = string
fqdns = optional(list(string))
ip_addresses = optional(list(string))
}))
description = "Backend address pools. Provide fqdns (e.g. App Service host) or ip_addresses per pool."
default = []
}
variable "health_probes" {
type = list(object({
name = string
protocol = string
path = string
host = optional(string)
interval = optional(number, 30)
timeout = optional(number, 30)
unhealthy_threshold = optional(number, 3)
status_codes = optional(list(string), ["200-399"])
}))
description = "Custom health probes. When host is null, the host is taken from the backend HTTP settings."
default = []
}
variable "backend_http_settings" {
type = list(object({
name = string
port = number
protocol = string
cookie_based_affinity = optional(string, "Disabled")
request_timeout = optional(number, 30)
probe_name = optional(string)
pick_host_name_from_backend_address = optional(bool, true)
}))
description = "Backend HTTP settings (port, protocol, affinity, probe binding)."
default = []
}
variable "https_listeners" {
type = list(object({
name = string
ssl_certificate_name = string
host_name = optional(string)
}))
description = "HTTPS (port 443) listeners. Each binds an ssl_certificate_name and optional host_name for multi-site routing."
default = []
validation {
condition = length(var.https_listeners) > 0
error_message = "At least one HTTPS listener is required (the module wires HTTP->HTTPS redirect to the first one)."
}
}
variable "routing_rules" {
type = list(object({
name = string
listener_name = string
backend_pool_name = string
backend_http_settings_name = string
priority = number
}))
description = "Basic request routing rules binding a listener to a backend pool + settings. Priority must be unique (100 is reserved for the redirect)."
default = []
validation {
condition = alltrue([for r in var.routing_rules : r.priority > 100 && r.priority <= 20000])
error_message = "routing_rules priority must be between 101 and 20000 (100 is reserved for the HTTP->HTTPS redirect)."
}
}
variable "waf" {
type = object({
enabled = optional(bool, true)
firewall_mode = optional(string, "Prevention")
rule_set_version = optional(string, "3.2")
file_upload_limit_mb = optional(number, 100)
max_request_body_size_kb = optional(number, 128)
})
description = "OWASP WAF configuration. Applies only to the WAF_v2 SKU. Set to null to omit (e.g. for Standard_v2)."
default = {
enabled = true
firewall_mode = "Prevention"
rule_set_version = "3.2"
file_upload_limit_mb = 100
max_request_body_size_kb = 128
}
validation {
condition = var.waf == null ? true : contains(["Detection", "Prevention"], var.waf.firewall_mode)
error_message = "waf.firewall_mode must be Detection or Prevention."
}
}
variable "tags" {
type = map(string)
description = "Tags applied to the Application Gateway."
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Application Gateway."
value = azurerm_application_gateway.this.id
}
output "name" {
description = "Name of the Application Gateway."
value = azurerm_application_gateway.this.name
}
output "backend_address_pool_ids" {
description = "Map of backend pool name => pool block (id and name) for downstream attachment."
value = {
for pool in azurerm_application_gateway.this.backend_address_pool :
pool.name => { id = pool.id, name = pool.name }
}
}
output "frontend_ip_configuration" {
description = "Frontend IP configuration block (id, name, public/private IP wiring)."
value = azurerm_application_gateway.this.frontend_ip_configuration
}
output "identity_principal_id" {
description = "Principal ID of the user-assigned identity in use, if any (handy for Key Vault access policies)."
value = try(azurerm_application_gateway.this.identity[0].identity_ids[0], null)
}
How to use it
# A dedicated subnet, a Standard static public IP, and a UAMI that can read certs.
resource "azurerm_public_ip" "appgw" {
name = "pip-appgw-prod-cin"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
allocation_method = "Static"
sku = "Standard"
zones = ["1", "2", "3"]
}
resource "azurerm_user_assigned_identity" "appgw" {
name = "id-appgw-prod-cin"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
}
# Let the gateway identity read the TLS cert secret from Key Vault.
resource "azurerm_key_vault_access_policy" "appgw_certs" {
key_vault_id = azurerm_key_vault.platform.id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = azurerm_user_assigned_identity.appgw.principal_id
secret_permissions = ["Get"]
}
module "application_gateway" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-application-gateway?ref=v1.0.0"
name = "agw-prod-cin"
resource_group_name = azurerm_resource_group.net.name
location = "centralindia"
subnet_id = azurerm_subnet.appgw.id
public_ip_address_id = azurerm_public_ip.appgw.id
identity_ids = [azurerm_user_assigned_identity.appgw.id]
autoscale = {
min_capacity = 2
max_capacity = 10
}
ssl_certificates = [{
name = "kloudvin-wildcard"
key_vault_secret_id = azurerm_key_vault_certificate.wildcard.versionless_secret_id
}]
backend_pools = [{
name = "pool-webapp"
fqdns = [azurerm_linux_web_app.site.default_hostname]
}]
health_probes = [{
name = "probe-webapp"
protocol = "Https"
path = "/healthz"
status_codes = ["200-299"]
}]
backend_http_settings = [{
name = "https-webapp"
port = 443
protocol = "Https"
probe_name = "probe-webapp"
}]
https_listeners = [{
name = "listener-app"
ssl_certificate_name = "kloudvin-wildcard"
host_name = "app.kloudvin.com"
}]
routing_rules = [{
name = "rule-app"
listener_name = "listener-app"
backend_pool_name = "pool-webapp"
backend_http_settings_name = "https-webapp"
priority = 110
}]
waf = {
enabled = true
firewall_mode = "Prevention"
rule_set_version = "3.2"
}
tags = {
environment = "prod"
owner = "platform-team"
}
}
# Downstream: point DNS at the gateway's public IP via the frontend output.
resource "azurerm_dns_a_record" "app" {
name = "app"
zone_name = azurerm_dns_zone.kloudvin.name
resource_group_name = azurerm_resource_group.dns.name
ttl = 300
target_resource_id = azurerm_public_ip.appgw.id
}
# Downstream: reference a backend pool id from the module output (e.g. for diagnostics or docs).
output "webapp_pool_id" {
value = module.application_gateway.backend_address_pool_ids["pool-webapp"].id
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/application_gateway/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-application-gateway?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
subnet_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/application_gateway && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| name | string | — | yes | Gateway name; prefix for derived child config names (3-80 chars, validated). |
| resource_group_name | string | — | yes | Resource group for the gateway. |
| location | string | — | yes | Azure region (e.g. centralindia). |
| subnet_id | string | — | yes | Dedicated subnet ID (Application Gateway instances only; /24+ for v2). |
| public_ip_address_id | string | null | no | Standard static public IP ID. Null creates an internal gateway. |
| private_ip_address | string | null | no | Static private IP for an internal gateway (required when public IP is null). |
| sku_name | string | “WAF_v2” | no | Standard_v2 or WAF_v2 (validated). |
| sku_tier | string | “WAF_v2” | no | Standard_v2 or WAF_v2 (validated). |
| capacity | number | 2 | no | Fixed instance count; used only when autoscale is null (1-125). |
| autoscale | object | { min=2, max=10 } | no | Autoscale bounds for v2; null falls back to fixed capacity. |
| zones | list(string) | [“1”,“2”,“3”] | no | Availability zones; empty list disables zone redundancy. |
| identity_ids | list(string) | [] | no | User-assigned identity IDs; required for Key Vault certs. |
| ssl_certificates | list(object) | [] | no | Key Vault certs (use versionless secret id for auto-rotation). |
| backend_pools | list(object) | [] | no | Backend pools with fqdns and/or ip_addresses. |
| health_probes | list(object) | [] | no | Custom probes; host defaults from backend settings when null. |
| backend_http_settings | list(object) | [] | no | Backend HTTP settings (port, protocol, affinity, probe binding). |
| https_listeners | list(object) | [] | yes* | HTTPS listeners; at least one required (validated). |
| routing_rules | list(object) | [] | no | Listener-to-backend rules; priority 101-20000 (100 reserved). |
| waf | object | OWASP 3.2 Prevention | no | WAF config for WAF_v2; null to omit. |
| tags | map(string) | {} | no | Tags applied to the gateway. |
Outputs
| Name | Description |
|---|---|
| id | Resource ID of the Application Gateway. |
| name | Name of the Application Gateway. |
| backend_address_pool_ids | Map of backend pool name to { id, name } for downstream attachment. |
| frontend_ip_configuration | Frontend IP configuration block (id, name, public/private IP wiring). |
| identity_principal_id | Principal ID of the user-assigned identity, for Key Vault access policies. |
Enterprise scenario
A retail platform on Azure Central India runs its customer storefront on App Service and its checkout API on an AKS cluster, both behind a single regional ingress tier. The platform team consumes this module once per landing zone: app.retailco.in and api.retailco.in resolve to one zone-redundant WAF_v2 gateway that terminates a Key Vault wildcard cert, auto-rotates it on renewal, and enforces OWASP 3.2 in Prevention mode to block injection and bot traffic before it reaches PCI-scoped backends. Because the module’s lifecycle.ignore_changes covers pools and listeners, the AKS Application Gateway Ingress Controller (AGIC) can manage its own routes at runtime without Terraform reverting them on the next pipeline run.
Best practices
- Dedicate the subnet and size it for autoscale. Put nothing but the gateway in
subnet_id, and use at least a/24forWAF_v2— autoscaling instances each consume IPs, and an undersized subnet silently caps scale-out. Never share this subnet with NICs or private endpoints. - Source TLS certs from Key Vault with the versionless secret id. Passing
versionless_secret_idlets the gateway poll for new versions and rotate automatically, so a renewed certificate never requires aterraform apply. Keep PFX files and passwords out of state entirely. - Run WAF in Prevention with OWASP CRS 3.2, then tune. Start in
Detectiononly long enough to triage false positives, then switch toPrevention. Pinrule_set_versionexplicitly so a provider or platform upgrade never silently changes which rules block traffic. - Always front backends with health probes and matching status codes. A custom
probewith a real/healthzpath and tightstatus_codes(e.g.200-299) keeps unhealthy instances out of rotation; relying on the default probe masks partial outages behind a 200-or-anything check. - Keep zone redundancy and autoscale on by default for production.
zones = ["1","2","3"]plus amin_capacityof 2 survives a zone failure with no cold start; for cost-sensitive non-prod, drop toStandard_v2, setautoscale = null, and usecapacity = 1. - Name consistently and let the module derive child names. Use a
agw-<env>-<region>convention forname; the module builds listener, port, and redirect names from it, so rules and settings never collide across environments and diffs stay readable.