IaC Azure

Terraform Module: Azure Application Gateway — WAF-protected L7 ingress in one reusable block

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

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 configlive/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 configlive/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

TerraformAzureApplication GatewayModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading