Skip to main content

Application Gateway

Application Gateway comes up in almost every enterprise design conversation eventually — usually when a team needs Layer 7 routing, WAF protection, or a path to expose internal services through Private Link. The gotchas in this space are significant enough that I keep coming back to them.

WAF vs Standard SKU

I default to WAF_v2 even when WAF isn't a stated requirement. The cost delta between Standard_v2 and WAF_v2 is small relative to later migration pain if threats or compliance requirements change. WAF starts in Detection mode, which logs but doesn't block — I use that during rollout to understand what rules would fire before switching to Prevention.

The two modes:

  • Detection: Logs rule matches, takes no action. Use during initial deployment to baseline false positives.
  • Prevention: Logs and blocks. Once you've reviewed Detection logs for 1-2 weeks and tuned exclusions, flip to this.
resource "azurerm_application_gateway" "main" {
name = "appgw-${var.workload}-${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location

sku {
name = "WAF_v2"
tier = "WAF_v2"
capacity = 2
}

waf_configuration {
enabled = true
firewall_mode = "Prevention" # Start with Detection, flip when ready
rule_set_type = "OWASP"
rule_set_version = "3.2"
}

# ... gateway_ip_configuration, frontend_port, frontend_ip_configuration, etc.
}

NSG requirements for the App GW subnet

This is the one that catches people most often. App Gateway v2 requires specific inbound NSG rules on its subnet — without them the gateway fails health checks and backends appear unhealthy.

Required inbound rules:

PrioritySourceSource PortDestinationDestination PortAction
100GatewayManagerAnyAny65200-65535Allow
110AzureLoadBalancerAnyAnyAnyAllow
120Internet (or specific CIDR)AnyAny80, 443Allow

The 65200-65535 range is the App Gateway infrastructure communication port range. Blocking it causes the gateway to appear healthy in the portal but backends to show as "Unknown" or "Unhealthy."

resource "azurerm_network_security_group" "appgw" {
name = "nsg-appgw-${var.environment}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name

security_rule {
name = "AllowGatewayManager"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "*"
source_port_range = "*"
destination_port_range = "65200-65535"
source_address_prefix = "GatewayManager"
destination_address_prefix = "*"
}

security_rule {
name = "AllowAzureLoadBalancer"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "AzureLoadBalancer"
destination_address_prefix = "*"
}

security_rule {
name = "AllowHTTPSInbound"
priority = 120
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_ranges = ["80", "443"]
source_address_prefix = "Internet"
destination_address_prefix = "*"
}
}

This is the one that generates the most confusion: you cannot use Private Link with a private-only Application Gateway.

The scenario that comes up: "We want an internal Application Gateway with no public IP, and we want to expose it to consumers in other VNets or tenants via Private Link." This combination doesn't work. Private Link for App Gateway requires a frontend public IP to be present on the gateway.

The options:

Option 1: Add a public IP but block all inbound public traffic via NSG

The public IP satisfies the Private Link prerequisite. An NSG on the App GW subnet denies all inbound internet traffic. Private Link consumers access the gateway via the Private Endpoint, which routes to the private IP internally — they're never touching the public frontend.

# App GW with both public and private frontends
resource "azurerm_public_ip" "appgw" {
name = "pip-appgw-${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
allocation_method = "Static"
sku = "Standard"
}

# NSG rule blocking all internet inbound on 80/443
security_rule {
name = "DenyInternetInbound"
priority = 4000
direction = "Inbound"
access = "Deny"
protocol = "Tcp"
source_port_range = "*"
destination_port_ranges = ["80", "443"]
source_address_prefix = "Internet"
destination_address_prefix = "*"
}

Option 2: Private-only App GW (no Private Link)

If Private Link isn't needed and the gateway only serves internal consumers, use a private-only frontend IP with no public IP resource. This is simpler, but consumers must be in the same VNet or a peered VNet — no cross-tenant access.

The EnableApplicationGatewayNetworkIsolation feature flag enables this configuration on Standard_v2/WAF_v2. This mode is mutually exclusive with Private Link.

When Private Link is required, the Private Link service is configured on a dedicated subnet separate from the App GW subnet:

# Step 1: Create dedicated Private Link subnet
az network vnet subnet update \
--name AppGW-PrivateLink-Subnet \
--vnet-name $VNET_NAME \
--resource-group $RG \
--disable-private-link-service-network-policies true

# Step 2: Add Private Link configuration to the App GW frontend
az network application-gateway private-link add \
--gateway-name $APPGW_NAME \
--resource-group $RG \
--name MyPrivateLinkConfig \
--frontend-ip appGwPublicFrontendIp \
--subnet /subscriptions/$SUB_ID/resourceGroups/$RG/providers/Microsoft.Network/virtualNetworks/$VNET/subnets/AppGW-PrivateLink-Subnet \
--ip-address 10.1.2.10

Key constraints:

  • Minimum /28 subnet for the Private Link configuration (AppGW-PrivateLink-Subnet)
  • Each Private Link IP supports ~65,536 concurrent TCP connections
  • Up to 8 IPs per Private Link configuration for scaling
  • Standard_v2 or WAF_v2 SKU required — Basic SKU not supported

Backend health probe failures

When backends show as unhealthy, the first three things I check:

1. Health probe path returning non-200

The default health probe hits / expecting HTTP 200. If the application returns 302 (redirect to login) or 401, the probe fails. Fix by creating a custom probe pointed at a path that returns 200:

probe {
name = "probe-${var.backend_name}"
protocol = "Https"
path = "/health"
interval = 30
timeout = 30
unhealthy_threshold = 3
pick_host_name_from_backend_http_settings = true
}

2. Backend NSG blocking the probe source

App Gateway health probes originate from the gateway subnet CIDR, not from a service tag. The backend NSG must allow inbound on the application port from the App GW subnet range.

3. SSL certificate mismatch on backend

When end-to-end TLS is configured, the backend certificate must be trusted by the App GW. If using a self-signed certificate on the backend, upload it as a trusted root certificate in the HTTP settings:

backend_http_settings {
name = "https-settings"
cookie_based_affinity = "Disabled"
port = 443
protocol = "Https"
request_timeout = 60
trusted_root_certificate_names = ["backend-root-cert"]
}