Every public workload on Azure eventually needs something in front of it — something that owns the public IP, spreads traffic across more than one instance, notices when an instance goes bad, and (if it speaks HTTP) terminates TLS and screens hostile requests before they reach your app. Azure gives you two very different boxes for that job, and picking the wrong one is one of the most expensive architecture mistakes a team makes: the Azure Load Balancer is a layer-4 (TCP/UDP) distributor that never opens the packet, and the Application Gateway is a layer-7 (HTTP) reverse proxy that reads the URL, terminates TLS, and can run a Web Application Firewall. Clicking these together in the portal is slow, undocumented, and impossible to reproduce across environments. This lesson builds the whole tier in Terraform, the way you would run it in production.
By the end you will have stood up, from an empty directory, a real Application Gateway (WAF_v2) with an HTTPS listener, a routing rule, a health probe and an OWASP WAF policy, load-balancing across a two-VM backend pool running nginx — then curled its public IP to watch it serve, read its backend health, and torn it all down with terraform destroy. You will also build the L4 alternative — an azurerm_lb (Standard SKU) with a health probe, load-balancing rule, outbound rule and inbound NAT — and know exactly when each is the right tool. Above all you will leave able to diagnose the error that every first Application Gateway throws at you: 502 Bad Gateway, whose real cause is almost never the gateway and almost always a failing probe, a blocking NSG, a wrong backend port, or a Key Vault permission.
This is the provider-specific, hands-on layer of the course. It assumes you already know core Terraform — HCL, providers, resources, variables, state and modules from the Foundation and Intermediate tiers — and applies it to a real cloud, relentlessly, with copy-pasteable .tf files and a terraform init → plan → apply → verify → destroy you actually run.
What you’ll build
The scenario is the one you meet on day one of almost any Azure project: a web application that must be reachable on a public HTTPS endpoint, survive a single VM dying, offload TLS at the edge, route /api/* to a different backend than /*, and refuse the obvious OWASP attacks. That is an Application Gateway WAF_v2 job end to end. Behind it sits a backend pool of two Linux VMs (you would use a VM Scale Set in production; we show both) each running nginx, in their own subnet, reachable only from the gateway. The gateway lives in its own dedicated subnet — Application Gateway refuses to share — owns a Standard static public IP, terminates TLS using a certificate it reads from Key Vault through a managed identity, and only forwards a request to a backend instance that is currently passing a health probe.
Alongside it we build the layer-4 story, because half the time an L7 gateway is the wrong answer. An Azure Load Balancer (Standard SKU) is what you put in front of raw TCP or UDP — a database read-replica pool, a game server fleet, a non-HTTP protocol, or any case where you want the lowest possible latency and don’t need to read the request. We wire an azurerm_lb with a frontend IP, a backend pool, a health probe, a load-balancing rule, an outbound rule for deterministic SNAT, and an inbound NAT rule for direct SSH to one node.
Why Terraform rather than the portal, the Azure CLI, or ARM/Bicep? Because this tier is a graph of a dozen tightly-coupled resources — subnets, a public IP, an NSG, the gateway’s frontend/listener/pool/settings/probe/rule blocks, the WAF policy, the identity, the Key Vault access — and every one of them has an ID that another one references. Terraform’s dependency graph wires those references for you, plans the exact diff before touching anything, and lets you stamp the identical gateway into dev, staging and prod from one module with different variables. The portal gives you none of that; Bicep gives you the graph but not the multi-cloud state model, the plan preview, or the for_each ergonomics you already know.
Reading that diagram left to right is reading the request path you are about to build: the client hits the gateway’s public IP on 443, the HTTPS listener terminates TLS with the Key Vault cert, the WAF policy screens the request, the routing rule selects the backend pool, and the health probe guarantees the chosen VM is alive before the gateway forwards. The purple L4 zone is the Load Balancer alternative — same backend pool, but reached on raw TCP with no TLS and no firewall.
Here is the full inventory of what a single terraform apply will create, and roughly what each part costs if you leave it running (India South, pay-as-you-go, indicative July 2026):
| Resource (Terraform) | Azure object | Role in the build | Rough cost if left up |
|---|---|---|---|
azurerm_resource_group |
Resource group | Container + lifecycle boundary | Free |
azurerm_virtual_network + 2×azurerm_subnet |
VNet, appgw subnet, backend subnet | Isolation; AppGw needs its own subnet | Free |
azurerm_public_ip (Standard, Static) |
Public IP | Gateway frontend | ~₹300/mo (~$3.60) |
azurerm_network_security_group (+ rules) |
NSG on backend subnet | Allow gateway → backend :80 | Free |
azurerm_linux_virtual_machine ×2 |
2× Standard_B1s VM + nginx |
The backend pool | ~₹1,400/mo each |
azurerm_user_assigned_identity |
Managed identity | Gateway reads Key Vault cert | Free |
azurerm_key_vault + cert |
Key Vault + self-signed cert | Listener TLS certificate | ~₹0 + tiny op cost |
azurerm_web_application_firewall_policy |
WAF policy | OWASP rules, Prevention mode | Included in WAF_v2 |
azurerm_application_gateway (WAF_v2) |
Application Gateway | The L7 edge | ~₹18,000/mo + capacity units |
That Application Gateway is the expensive line item — a WAF_v2 has a fixed hourly charge plus per–capacity-unit billing, so this is emphatically a build it, verify it, destroy it lesson, not one to leave running overnight. Every costly or destructive step below is marked ⚠️.
Where this fits: the backend pool here is deliberately thin so the lesson stays about load balancing. The compute side — cloud-init, VM sizing, and turning two VMs into a scale set — is the subject of the Azure VMs, VMSS & cloud-init lesson. If your backend is a PaaS web app rather than VMs, the App Service, Web Apps & slots lesson is the backend you would point this gateway at. And the Key Vault the listener reads its certificate from is built properly in the Key Vault, SQL Database & DNS lesson; here we create a minimal one inline.
L4 vs L7: choosing the right Azure load balancer
The single decision that governs this whole tier is which layer of the network stack you balance at. Azure Load Balancer operates at layer 4 — it forwards TCP and UDP flows by a hash of the 5-tuple (source IP, source port, destination IP, destination port, protocol) and never inspects the payload. It does not know or care whether the bytes are HTTP, TLS, Redis or a game protocol. Application Gateway operates at layer 7 — it is a full reverse proxy that terminates the TCP connection, reads the HTTP request line and headers, can decrypt TLS, and makes routing decisions on hostname, path and headers. That difference cascades into everything each one can and cannot do:
| Capability | Azure Load Balancer (L4) | Application Gateway (L7) |
|---|---|---|
| OSI layer | 4 (TCP/UDP) | 7 (HTTP/HTTPS) |
| Reads URL / host / headers | No | Yes |
| TLS termination / offload | No (passes bytes through) | Yes (listener certificate) |
| Path-based / multi-site routing | No | Yes |
| Web Application Firewall (WAF) | No | Yes (WAF_v2 tier) |
| Cookie-based session affinity | No (5-tuple/2-tuple hash only) | Yes (cookie_based_affinity) |
| Protocols | Any TCP/UDP | HTTP, HTTPS, HTTP/2, WebSocket |
| Health probe types | TCP, HTTP, HTTPS | HTTP, HTTPS (with body/status match) |
| Latency overhead | Lowest (pass-through) | Higher (proxy + inspect) |
| Rewrite headers / redirect | No | Yes (rewrite rules, redirects) |
| Typical price floor | Low (per-rule, cheap) | High (fixed hourly + capacity) |
| Terraform resource | azurerm_lb |
azurerm_application_gateway |
The rule of thumb writes itself: if the traffic is HTTP/S and you need to route on URL, terminate TLS, or run a firewall, use Application Gateway. If it is raw TCP/UDP, or you want the lowest latency and cheapest bill and don’t need to read the request, use Load Balancer. A great many designs use both: an Application Gateway for the public web tier, and internal Standard Load Balancers deep inside the VNet fronting database or cache pools.
Azure actually ships four services in this space, and choosing among them is the framing decision. The table below is the one to keep open:
| Service | Layer | Scope | Best for | WAF? | Terraform resource |
|---|---|---|---|---|---|
| Azure Load Balancer | L4 (TCP/UDP) | Regional | Raw TCP/UDP, internal tiers, lowest latency | No | azurerm_lb |
| Application Gateway | L7 (HTTP) | Regional | Web apps needing path routing, TLS, WAF, in one VNet | Yes (WAF_v2) | azurerm_application_gateway |
| Azure Front Door | L7 (HTTP) | Global | Global HTTP entry, CDN, edge WAF, multi-region failover | Yes (Premium) | azurerm_cdn_frontdoor_* |
| Traffic Manager | DNS | Global | DNS-level routing across regions/endpoints | No | azurerm_traffic_manager_profile |
Two orthogonal axes separate them. First, L4 vs L7: Load Balancer is L4; the other three make L7/DNS decisions. Second, regional vs global: Load Balancer and Application Gateway are regional — they live in one region’s VNet and give you a regional endpoint; Front Door and Traffic Manager are global — they sit at Azure’s edge and route across regions. And public vs internal cuts across both: a Load Balancer or Application Gateway can have a public frontend IP (internet-facing) or a private one (internal-only, for VNet-internal traffic), set purely by whether the frontend configuration references a public IP or a subnet + private IP.
Azure Front Door, in one paragraph, is the global alternative to Application Gateway. Where Application Gateway is a regional L7 proxy you place inside a VNet, Front Door is a global L7 service that lives at Azure’s ~200 edge POPs: it gives you anycast entry, TLS termination and caching at the edge nearest the user, a global WAF (Premium tier), and health-probe-based failover across regions. You reach for Front Door when users are global and you run more than one region and want the edge to absorb TLS and cache static assets; you reach for Application Gateway when the workload is one region and you want fine-grained routing and WAF inside your own VNet. They compose: a common enterprise pattern is Front Door (global edge + WAF) → Application Gateway (regional WAF + routing) → backend, and if you build that, you set Application Gateway’s listener to only accept traffic from Front Door. In Terraform, Front Door Standard/Premium is the azurerm_cdn_frontdoor_profile, _endpoint, _origin_group, _origin, _route and _firewall_policy family — a separate lesson’s worth of resources.
Azure Load Balancer (L4) with Terraform
Start with the L4 box, because it is simpler and it teaches the vocabulary — frontend, backend pool, probe, rule — that Application Gateway reuses at L7. The azurerm_lb resource is almost empty on its own; a working Load Balancer is five resources wired by ID.
First, the load balancer and its frontend. Always choose the Standard SKU for anything new — the Basic SKU is on a retirement path (retiring September 2025), has no SLA, no zone-redundancy, and a security model that is open by default rather than closed. Standard is secure-by-default (traffic is denied unless an NSG allows it) and zone-redundant.
resource "azurerm_public_ip" "lb" {
name = "pip-lb-web"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
allocation_method = "Static" # Standard SKU requires Static
sku = "Standard" # must match the LB SKU
zones = ["1", "2", "3"]
}
resource "azurerm_lb" "web" {
name = "lb-web"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
sku = "Standard"
frontend_ip_configuration {
name = "public-fe"
public_ip_address_id = azurerm_public_ip.lb.id
}
}
For an internal load balancer you drop the public IP and give the frontend a subnet and a private IP instead — one attribute flips public to internal:
frontend_ip_configuration {
name = "internal-fe"
subnet_id = azurerm_subnet.app.id
private_ip_address = "10.10.1.10"
private_ip_address_allocation = "Static" # or "Dynamic"
}
The azurerm_lb frontend arguments in full:
| Argument | Purpose | Public LB | Internal LB |
|---|---|---|---|
name |
Frontend config name (referenced by rules) | required | required |
public_ip_address_id |
The public IP to own | set this | omit |
subnet_id |
Subnet the private frontend lives in | omit | set this |
private_ip_address |
Fixed private IP | n/a | optional (Static) |
private_ip_address_allocation |
Static or Dynamic |
n/a | with subnet_id |
zones |
Zone-redundancy for the frontend | optional | optional |
gateway_load_balancer_frontend_ip_configuration_id |
Chain to a Gateway LB | optional | optional |
Next the backend pool — an empty container you attach compute to:
resource "azurerm_lb_backend_address_pool" "web" {
name = "bepool-web"
loadbalancer_id = azurerm_lb.web.id
}
There are three ways to put instances into that pool, and picking the right one avoids a lot of pain:
| Method | Terraform resource / block | Use when |
|---|---|---|
| NIC association | azurerm_network_interface_backend_address_pool_association |
A handful of individually-managed VMs |
| IP-based membership | azurerm_lb_backend_address_pool_address |
Backends by raw IP (incl. across peered VNets) |
| VMSS attachment | load_balancer_backend_address_pool_ids in the scale set’s network_interface.ip_configuration |
Production — the scale set self-registers instances |
For a scale set, the VMSS registers its own instances into the pool — you never manage individual members. That is the production pattern and it is one line inside the scale set’s network profile:
resource "azurerm_linux_virtual_machine_scale_set" "web" {
# ... sku, instances, os, admin, image, custom_data ...
network_interface {
name = "nic"
primary = true
ip_configuration {
name = "ipcfg"
primary = true
subnet_id = azurerm_subnet.app.id
# This one line wires every instance into the LB pool:
load_balancer_backend_address_pool_ids = [azurerm_lb_backend_address_pool.web.id]
}
}
}
Now the health probe. The probe is the entire reason a load balancer is useful: it is the mechanism by which a dead instance stops receiving traffic. The Load Balancer keeps polling each backend, and only instances currently passing the probe are eligible targets.
resource "azurerm_lb_probe" "web" {
name = "probe-http"
loadbalancer_id = azurerm_lb.web.id
protocol = "Http" # Tcp | Http | Https
port = 80
request_path = "/health" # required for Http/Https
interval_in_seconds = 5
number_of_probes = 2 # consecutive failures before "down"
probe_threshold = 1 # consecutive successes to mark "up"
}
Then the load-balancing rule ties frontend, pool and probe together and defines the port mapping. This is where you also control SNAT and floating IP:
resource "azurerm_lb_rule" "web" {
name = "rule-http"
loadbalancer_id = azurerm_lb.web.id
protocol = "Tcp"
frontend_port = 80
backend_port = 80
frontend_ip_configuration_name = "public-fe"
backend_address_pool_ids = [azurerm_lb_backend_address_pool.web.id]
probe_id = azurerm_lb_probe.web.id
idle_timeout_in_minutes = 4
load_distribution = "Default" # 5-tuple hash
disable_outbound_snat = true # use an explicit outbound rule instead
enable_tcp_reset = true
}
The load-balancing rule arguments that matter:
| Argument | Meaning | Notes |
|---|---|---|
protocol |
Tcp, Udp, or All |
All (HA Ports) needs an internal Standard LB |
frontend_port / backend_port |
External → internal port map | Same or different |
probe_id |
Which probe gates this rule | Without it, no health gating |
load_distribution |
Default (5-tuple), SourceIP (2-tuple), SourceIPProtocol (3-tuple) |
Session persistence at L4 |
enable_floating_ip |
Direct Server Return (SQL AlwaysOn, etc.) | Backend must own the frontend IP |
disable_outbound_snat |
Turn off implicit SNAT on this rule | Pair with an explicit outbound rule |
idle_timeout_in_minutes |
4–30 min flow idle timeout | Raise for long-lived connections |
enable_tcp_reset |
Send RST on idle timeout | Cleaner client behaviour |
Two rule types round out a real Load Balancer. Outbound rules give your backends deterministic SNAT for egress — without one, Standard LB gives no outbound internet access at all (secure by default), and relying on implicit SNAT is how you hit SNAT port exhaustion under load:
resource "azurerm_lb_outbound_rule" "web" {
name = "outbound-all"
loadbalancer_id = azurerm_lb.web.id
protocol = "All"
backend_address_pool_id = azurerm_lb_backend_address_pool.web.id
allocated_outbound_ports = 10000 # ports per instance — size for concurrency
idle_timeout_in_minutes = 4
enable_tcp_reset = true
frontend_ip_configuration {
name = "public-fe"
}
}
And inbound NAT rules forward a specific frontend port to a specific backend instance — the classic “SSH to node 2 on port 50002” jump-host pattern, without giving each VM its own public IP:
resource "azurerm_lb_nat_rule" "ssh" {
name = "ssh-vm0"
resource_group_name = azurerm_resource_group.this.name
loadbalancer_id = azurerm_lb.web.id
protocol = "Tcp"
frontend_port = 50000
backend_port = 22
frontend_ip_configuration_name = "public-fe"
}
The three rule families compared, because mixing them up is a common mistake:
| Rule type | Resource | Direction | What it does |
|---|---|---|---|
| Load-balancing | azurerm_lb_rule |
Inbound | Spread a port across the whole pool |
| Inbound NAT | azurerm_lb_nat_rule |
Inbound | Forward a port to one instance |
| Outbound | azurerm_lb_outbound_rule |
Outbound | Deterministic SNAT for egress |
Application Gateway (L7) with Terraform
Application Gateway is a single Terraform resource — azurerm_application_gateway — but it is a large one: it collapses a dozen sub-objects into repeatable inner blocks, and every block references another by name (not by Terraform ID). That name-based wiring is the thing to internalise: the routing rule names a listener and a backend pool and an HTTP settings block; the listener names a frontend IP config, a frontend port, and an SSL certificate; the HTTP settings block names a probe. Get one name wrong and apply fails with a validation error pointing at the dangling reference.
Here is every block, what it models, and how it connects:
| Block | Models | Key arguments | References |
|---|---|---|---|
sku |
Tier + capacity | name, tier, capacity |
— |
autoscale_configuration |
Elastic capacity (v2) | min_capacity, max_capacity |
replaces capacity |
gateway_ip_configuration |
Which subnet the GW lives in | name, subnet_id |
the dedicated subnet |
frontend_ip_configuration |
Public/private entry IP | name, public_ip_address_id |
a public IP |
frontend_port |
Ports it listens on | name, port |
— |
http_listener |
An entry point (proto+port+host) | name, protocol, ssl_certificate_name, host_name |
frontend IP + port + cert |
ssl_certificate |
TLS cert for HTTPS listeners | name, key_vault_secret_id |
Key Vault |
backend_address_pool |
Where to send traffic | name, ip_addresses / fqdns |
— |
backend_http_settings |
How to talk to the backend | name, port, protocol, probe_name |
a probe |
probe |
Backend health check | name, path, interval, match |
— |
request_routing_rule |
Ties it all together | name, rule_type, priority |
listener + pool + settings |
url_path_map |
Path-based routing table | path_rule { paths, ... } |
pools + settings |
identity |
Managed identity for Key Vault | type, identity_ids |
a user-assigned identity |
waf_configuration / firewall_policy_id |
Web Application Firewall | mode, rule set / policy ID | a WAF policy |
SKU tiers. Application Gateway has a legacy v1 generation (Standard, WAF) and a current v2 generation (Standard_v2, WAF_v2). Always build v2 for anything new — v1 is deprecated and cannot autoscale, is not zone-redundant, and is much slower to provision. WAF_v2 is Standard_v2 plus the Web Application Firewall:
| SKU | Generation | WAF | Autoscale | Zone-redundant | Use for |
|---|---|---|---|---|---|
Standard |
v1 (legacy) | No | No | No | Do not use (deprecated) |
WAF |
v1 (legacy) | Yes | No | No | Do not use (deprecated) |
Standard_v2 |
v2 | No | Yes | Yes | L7 routing/TLS, no firewall |
WAF_v2 |
v2 | Yes | Yes | Yes | Public web apps needing a firewall |
The skeleton of the resource, block by block. Note zones, and autoscale_configuration instead of a fixed capacity:
resource "azurerm_application_gateway" "web" {
name = "agw-web"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
zones = ["1", "2", "3"]
firewall_policy_id = azurerm_web_application_firewall_policy.web.id
sku {
name = "WAF_v2"
tier = "WAF_v2"
}
autoscale_configuration {
min_capacity = 2
max_capacity = 10
}
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.agw.id]
}
gateway_ip_configuration {
name = "gw-ipcfg"
subnet_id = azurerm_subnet.appgw.id # dedicated subnet, nothing else in it
}
frontend_ip_configuration {
name = "public-fe"
public_ip_address_id = azurerm_public_ip.agw.id
}
frontend_port {
name = "https"
port = 443
}
}
Listeners. A listener is an entry point defined by a frontend IP config, a frontend port, a protocol and (for HTTPS) a certificate. There are two flavours: a Basic listener answers everything hitting that IP+port, while a multi-site listener is scoped to one or more hostnames, letting many sites share one public IP and port. Multi-site is how you host app.example.com and api.example.com on the same gateway.
| Listener type | Scoped by | Requires | Use for |
|---|---|---|---|
| Basic | frontend IP + port only | — | Single site behind the gateway |
| Multi-site | + host_name / host_names |
SNI on HTTPS | Many hostnames, one IP/port |
http_listener {
name = "https-listener"
frontend_ip_configuration_name = "public-fe"
frontend_port_name = "https"
protocol = "Https"
ssl_certificate_name = "listener-cert"
host_name = "app.kloudvin.dev" # omit for a Basic listener
require_sni = true # multi-site HTTPS
}
TLS termination from Key Vault. The right way to give a listener its certificate is not to embed a PFX in Terraform — that puts a secret in state. Instead, store the certificate in Key Vault and reference it by secret ID; the gateway reads it at runtime through its managed identity. The ssl_certificate block then holds only a pointer:
ssl_certificate {
name = "listener-cert"
key_vault_secret_id = azurerm_key_vault_certificate.listener.secret_id
}
Two cert sources, and why Key Vault wins:
| Source | HCL | Secret in state? | Rotation | Use when |
|---|---|---|---|---|
| Inline PFX | data (base64) + password |
Yes (avoid) | Re-apply Terraform | Quick demo only |
| Key Vault | key_vault_secret_id + identity |
No | Rotate in KV, no apply | Production |
For the managed identity to actually read the cert, it needs a Key Vault permission — Key Vault Secrets User under RBAC, or a certificates/secrets get access policy under the legacy model. Missing that permission is a top-three cause of a listener stuck in Unknown state and a 502.
Backend pool and HTTP settings. The pool is the set of targets (by IP or FQDN); the HTTP settings block is how the gateway talks to them — port, protocol, timeout, affinity, host header handling, and which probe to use:
backend_address_pool {
name = "vm-pool"
ip_addresses = [for vm in azurerm_linux_virtual_machine.web : vm.private_ip_address]
}
backend_http_settings {
name = "http-settings"
cookie_based_affinity = "Disabled"
port = 80
protocol = "Http"
request_timeout = 30
pick_host_name_from_backend_address = false
probe_name = "backend-probe"
}
The backend_http_settings arguments that cause the most 502s when wrong:
| Argument | Purpose | Common 502 trap |
|---|---|---|
port / protocol |
How to reach the backend | Backend on 8080 but settings say 80 |
probe_name |
Custom probe for this settings block | Probe path 404s → all instances “unhealthy” |
pick_host_name_from_backend_address |
Send backend’s own FQDN as Host | App expects a specific Host header |
host_name |
Override the Host header sent to backend | SNI/host mismatch on HTTPS backends |
trusted_root_certificate_names |
Trust a backend’s cert (end-to-end TLS) | Self-signed backend cert not trusted |
request_timeout |
Seconds before the GW gives up | Slow backend → 502 after timeout |
cookie_based_affinity |
Sticky sessions via a cookie | — |
The health probe is, again, the resource that makes the whole thing safe. An Application Gateway probe is richer than an L4 probe: it sends an actual HTTP request to a path and can match on both status code ranges and response body text, so “the process is up but returning a 500 error page” correctly reads as unhealthy. Here is one comprehensive comparison of the two probe types — the health-probe table to keep:
| Setting | azurerm_lb_probe (L4) |
Application Gateway probe (L7) |
What it controls |
|---|---|---|---|
| Protocol | Tcp / Http / Https |
Http / Https |
Layer of the check |
| Path | request_path (Http/s) |
path |
The URL probed |
| Interval | interval_in_seconds (5–?) |
interval (seconds) |
How often to probe |
| Timeout | (derived) | timeout (seconds) |
Wait before a probe fails |
| Failure threshold | number_of_probes |
unhealthy_threshold |
Consecutive fails → “down” |
| Host header | n/a | host / pick_host_name_from_backend_http_settings |
Host sent with the probe |
| Status match | 200 only | match { status_code = [...] } |
Which codes count as healthy |
| Body match | No | match { body = "..." } |
Substring that must appear |
| Port override | via rule | port |
Probe a different port |
probe {
name = "backend-probe"
protocol = "Http"
path = "/"
interval = 15
timeout = 15
unhealthy_threshold = 3
pick_host_name_from_backend_http_settings = true
match {
status_code = ["200-399"]
}
}
Routing rules and path-based routing. The request_routing_rule is the block that ties a listener to a backend. A Basic rule sends everything from its listener to one pool + settings. A PathBasedRouting rule delegates to a url_path_map, which sends /api/* to one pool and everything else (default_*) to another. On v2, every rule must carry a unique priority (1–20000; lower wins).
| Rule type | Sends to | Extra block | Use for |
|---|---|---|---|
Basic |
one pool + settings | — | Single-backend site |
PathBasedRouting |
pool chosen by URL path | url_path_map |
/api/* vs /* split |
request_routing_rule {
name = "route-all"
priority = 100 # unique, required on v2
rule_type = "Basic"
http_listener_name = "https-listener"
backend_address_pool_name = "vm-pool"
backend_http_settings_name = "http-settings"
}
Path-based routing splits by URL, delegating to a url_path_map:
request_routing_rule {
name = "route-paths"
priority = 110
rule_type = "PathBasedRouting"
http_listener_name = "https-listener"
url_path_map_name = "paths"
}
url_path_map {
name = "paths"
default_backend_address_pool_name = "vm-pool" # everything else → web pool
default_backend_http_settings_name = "http-settings"
path_rule {
name = "api"
paths = ["/api/*"]
backend_address_pool_name = "api-pool" # /api/* → api pool
backend_http_settings_name = "http-settings"
}
}
Autoscaling was already shown via autoscale_configuration — set a min_capacity you never want to drop below (2 for HA) and a max_capacity ceiling; the gateway sizes itself by compute-unit demand between them, and you are billed per capacity unit consumed. A fixed capacity (v2 without autoscale) is possible but rarely what you want.
WAF: OWASP rules, Prevention vs Detection
The WAF is what makes it a WAF_v2 and not just a Standard_v2. It inspects each HTTP request against the OWASP Core Rule Set — a large, managed body of signatures for SQL injection, cross-site scripting, remote-file-inclusion, protocol anomalies and the rest of the OWASP Top 10 — plus any custom rules you add. The most important operational lever is the mode, and getting it wrong in either direction hurts:
| Mode | What it does on a match | Risk | Use when |
|---|---|---|---|
| Detection | Logs the match, forwards the request | False sense of security — nothing is blocked | Onboarding: watch logs, tune false positives |
| Prevention | Blocks the request (403), logs it | False positives block real users | Steady state, after tuning |
The discipline every team learns the hard way: ship in Detection, live there long enough to see real traffic, tune out the false positives (a WAF will happily block a legitimate request whose body looks like SQL), then flip to Prevention. Flipping to Prevention on day one blocks real users; staying in Detection forever means the firewall does nothing.
There are two ways to attach a WAF, and the modern one is a separate policy resource rather than the inline waf_configuration block. The inline block still works on WAF_v2 but is effectively legacy; the standalone azurerm_web_application_firewall_policy is more capable (per-listener and per-path association, richer custom rules) and is what to use:
| Approach | HCL | Capabilities | Recommendation |
|---|---|---|---|
Inline waf_configuration {} |
block inside the gateway | Gateway-wide mode + OWASP | Legacy; avoid for new builds |
| WAF policy resource | azurerm_web_application_firewall_policy + firewall_policy_id |
+ custom rules, exclusions, per-path/per-listener attach | Use this |
The policy carries three things: policy_settings (mode, request-body checks, size limits), managed_rules (the OWASP rule set version, with per-rule overrides and exclusions), and custom_rules (your own match or rate-limit rules, evaluated by priority before the managed set):
resource "azurerm_web_application_firewall_policy" "web" {
name = "wafpol-web"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
policy_settings {
enabled = true
mode = "Prevention" # start "Detection", flip after tuning
request_body_check = true
max_request_body_size_in_kb = 128
file_upload_limit_in_mb = 100
}
managed_rules {
managed_rule_set {
type = "OWASP"
version = "3.2"
}
}
custom_rules {
name = "rate-limit-login"
priority = 10
rule_type = "RateLimitRule"
action = "Block"
rate_limit_duration = "OneMin"
rate_limit_threshold = 100
match_conditions {
match_variables {
variable_name = "RequestUri"
}
operator = "Contains"
negation_condition = false
match_values = ["/login"]
}
}
}
Managed rules versus custom rules — you almost always want both:
| Rule kind | Source | Examples | Actions |
|---|---|---|---|
| Managed (OWASP) | Microsoft-maintained CRS 3.2 | SQLi, XSS, RFI, protocol violations | Detection/Prevention, per-rule disable/override |
| Custom | You, by priority | Rate-limit /login, geo-block, IP allow/deny |
Allow, Block, Log |
Hands-on: build it with Terraform
⚠️ This provisions real, billable Azure resources — most notably a WAF_v2 Application Gateway (fixed hourly charge + capacity units) and two VMs. Follow it end to end, verify, then run the destroy step. Do not leave it up.
We now assemble everything above into one working project: an Application Gateway (WAF_v2) with an HTTPS listener whose cert comes from Key Vault, a routing rule, a health probe, and an OWASP WAF policy, load-balancing across two nginx VMs. Lay out the files:
mkdir -p agw-demo && cd agw-demo
touch versions.tf provider.tf variables.tf main.tf network.tf \
backend.tf gateway.tf outputs.tf
1. Pin Terraform and the provider (versions.tf). Pin azurerm with ~> so a plan in CI never silently changes behaviour, and use a remote backend — for Azure that is a Storage Account blob, which locks state with a blob lease:
# versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
tls = { source = "hashicorp/tls", version = "~> 4.0" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
# Remote state (Azure = Storage Account blob + lease lock).
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "kvtfstate2026"
container_name = "tfstate"
key = "agw-demo.tfstate"
}
}
2. Configure the provider (provider.tf). features {} is mandatory. Authenticate ahead of time with az login (interactive) or a service principal / OIDC in CI — never put credentials in HCL:
# provider.tf
provider "azurerm" {
features {}
# Subscription comes from `az login` or ARM_SUBSCRIPTION_ID.
}
data "azurerm_client_config" "current" {}
3. Variables (variables.tf). Parameterise region, sizes, admin user and the backend count so the same code stamps any environment:
# variables.tf
variable "prefix" {
type = string
default = "kv-agw"
}
variable "location" {
type = string
default = "centralindia"
}
variable "vm_count" {
type = number
default = 2
}
variable "vm_size" {
type = string
default = "Standard_B1s"
}
variable "admin_username" {
type = string
default = "azureadmin"
}
variable "admin_ssh_public_key" {
type = string
description = "SSH public key for the backend VMs (ssh-keygen -t ed25519)."
}
4. Resource group + network (main.tf, network.tf). The gateway needs its own subnet; the VMs go in a second subnet. The NSG on the backend subnet must allow the gateway subnet to reach port 80, and the gateway subnet needs the v2 management ports (65200–65535) open inbound or the gateway will not provision:
# main.tf
resource "azurerm_resource_group" "this" {
name = "${var.prefix}-rg"
location = var.location
tags = { project = "tf-course", lesson = "lb-appgw", owner = "vinod" }
}
# network.tf
resource "azurerm_virtual_network" "this" {
name = "${var.prefix}-vnet"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
address_space = ["10.20.0.0/16"]
}
resource "azurerm_subnet" "appgw" {
name = "snet-appgw" # dedicated to the gateway
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = ["10.20.1.0/24"]
}
resource "azurerm_subnet" "backend" {
name = "snet-backend"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = ["10.20.2.0/24"]
}
# NSG on the backend subnet: allow the gateway subnet in on :80, deny the rest.
resource "azurerm_network_security_group" "backend" {
name = "${var.prefix}-backend-nsg"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
security_rule {
name = "allow-appgw-http"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "10.20.1.0/24" # the gateway subnet
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "backend" {
subnet_id = azurerm_subnet.backend.id
network_security_group_id = azurerm_network_security_group.backend.id
}
resource "azurerm_public_ip" "agw" {
name = "${var.prefix}-pip"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
allocation_method = "Static" # v2 requires Static + Standard
sku = "Standard"
}
5. The backend pool: two nginx VMs (backend.tf). Each VM gets a NIC in the backend subnet and a cloud-init that installs nginx and writes a page naming the host, so we can see load balancing when we curl. Using count gives us a list we can fold straight into the gateway’s pool:
# backend.tf
locals {
cloud_init = base64encode(<<-CLOUDINIT
#cloud-config
package_update: true
packages: [nginx]
runcmd:
- echo "Hello from $(hostname) — backend behind Application Gateway" > /var/www/html/index.html
- systemctl enable nginx
- systemctl restart nginx
CLOUDINIT
)
}
resource "azurerm_network_interface" "vm" {
count = var.vm_count
name = "${var.prefix}-nic-${count.index}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
ip_configuration {
name = "ipcfg"
subnet_id = azurerm_subnet.backend.id
private_ip_address_allocation = "Dynamic"
}
}
resource "azurerm_linux_virtual_machine" "web" {
count = var.vm_count
name = "${var.prefix}-vm-${count.index}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
size = var.vm_size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.vm[count.index].id]
custom_data = local.cloud_init
admin_ssh_key {
username = var.admin_username
public_key = var.admin_ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
}
Production note: replace the two
countVMs with anazurerm_linux_virtual_machine_scale_setwhosenetwork_interface.ip_configurationsetsapplication_gateway_backend_address_pool_ids = [<pool id>]. The scale set then self-registers every instance into the gateway pool — you stop managing the pool membership by hand. The VMs & VMSS lesson builds that scale set in full.
6. Identity, Key Vault cert, WAF policy, and the gateway (gateway.tf). A user-assigned identity gets Key Vault Secrets User on a Key Vault holding a self-signed certificate; the gateway’s HTTPS listener reads that cert by secret ID:
# gateway.tf
resource "azurerm_user_assigned_identity" "agw" {
name = "${var.prefix}-id"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
}
resource "azurerm_key_vault" "this" {
name = "${var.prefix}-kv-${substr(md5(azurerm_resource_group.this.id), 0, 6)}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
enable_rbac_authorization = true
}
# The person running Terraform needs to create the cert:
resource "azurerm_role_assignment" "kv_admin" {
scope = azurerm_key_vault.this.id
role_definition_name = "Key Vault Administrator"
principal_id = data.azurerm_client_config.current.object_id
}
# The gateway's identity needs to READ the cert secret:
resource "azurerm_role_assignment" "kv_reader" {
scope = azurerm_key_vault.this.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.agw.principal_id
}
resource "azurerm_key_vault_certificate" "listener" {
name = "listener-cert"
key_vault_id = azurerm_key_vault.this.id
depends_on = [azurerm_role_assignment.kv_admin]
certificate_policy {
issuer_parameters { name = "Self" }
key_properties {
exportable = true
key_type = "RSA"
key_size = 2048
reuse_key = true
}
secret_properties { content_type = "application/x-pkcs12" }
x509_certificate_properties {
subject = "CN=app.kloudvin.dev"
validity_in_months = 12
key_usage = ["digitalSignature", "keyEncipherment"]
}
}
}
resource "azurerm_web_application_firewall_policy" "web" {
name = "${var.prefix}-wafpol"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
policy_settings {
enabled = true
mode = "Prevention" # start with "Detection" in real onboarding
}
managed_rules {
managed_rule_set {
type = "OWASP"
version = "3.2"
}
}
}
Then the gateway itself, folding in the two VMs’ private IPs:
resource "azurerm_application_gateway" "web" {
name = "${var.prefix}-agw"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
zones = ["1", "2", "3"]
firewall_policy_id = azurerm_web_application_firewall_policy.web.id
sku {
name = "WAF_v2"
tier = "WAF_v2"
}
autoscale_configuration {
min_capacity = 2
max_capacity = 10
}
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.agw.id]
}
gateway_ip_configuration {
name = "gw-ipcfg"
subnet_id = azurerm_subnet.appgw.id
}
frontend_ip_configuration {
name = "public-fe"
public_ip_address_id = azurerm_public_ip.agw.id
}
frontend_port {
name = "https"
port = 443
}
ssl_certificate {
name = "listener-cert"
key_vault_secret_id = azurerm_key_vault_certificate.listener.secret_id
}
http_listener {
name = "https-listener"
frontend_ip_configuration_name = "public-fe"
frontend_port_name = "https"
protocol = "Https"
ssl_certificate_name = "listener-cert"
}
backend_address_pool {
name = "vm-pool"
ip_addresses = [for vm in azurerm_linux_virtual_machine.web : vm.private_ip_address]
}
probe {
name = "backend-probe"
protocol = "Http"
path = "/"
interval = 15
timeout = 15
unhealthy_threshold = 3
pick_host_name_from_backend_http_settings = true
match { status_code = ["200-399"] }
}
backend_http_settings {
name = "http-settings"
cookie_based_affinity = "Disabled"
port = 80
protocol = "Http"
request_timeout = 30
pick_host_name_from_backend_address = false
probe_name = "backend-probe"
}
request_routing_rule {
name = "route-all"
priority = 100
rule_type = "Basic"
http_listener_name = "https-listener"
backend_address_pool_name = "vm-pool"
backend_http_settings_name = "http-settings"
}
depends_on = [azurerm_role_assignment.kv_reader]
}
7. Outputs (outputs.tf). Emit the public IP so you can curl it:
# outputs.tf
output "gateway_public_ip" {
value = azurerm_public_ip.agw.ip_address
}
output "backend_private_ips" {
value = [for vm in azurerm_linux_virtual_machine.web : vm.private_ip_address]
}
8. Init. Downloads providers and wires the backend:
terraform init
# Initializing the backend...
# Initializing provider plugins...
# - Installing hashicorp/azurerm v4.x ...
# Terraform has been successfully initialized!
9. Plan. Pass the SSH key and read the summary line — it must create the whole graph and change nothing unexpected:
export TF_VAR_admin_ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)"
terraform plan
# ...
# Plan: 18 to add, 0 to change, 0 to destroy.
# Changes to Outputs:
# + gateway_public_ip = (known after apply)
10. Apply. ⚠️ Billing starts here. The gateway is the slow resource — a WAF_v2 typically takes 6–8 minutes to provision, so the whole apply runs ~10–12 minutes:
terraform apply -auto-approve
# azurerm_key_vault_certificate.listener: Creation complete after 20s
# azurerm_linux_virtual_machine.web[0]: Creation complete after 45s
# azurerm_application_gateway.web: Still creating... [6m0s elapsed]
# azurerm_application_gateway.web: Creation complete after 7m10s
# Apply complete! Resources: 18 added, 0 changed, 0 destroyed.
# Outputs:
# gateway_public_ip = "20.235.xx.xx"
11. Verify — curl the public IP and read backend health. Because the cert is self-signed, curl with -k. Repeat the curl a few times and watch the hostname change as the gateway spreads requests across both VMs:
IP=$(terraform output -raw gateway_public_ip)
curl -k https://$IP/
# Hello from kv-agw-vm-0 — backend behind Application Gateway
curl -k https://$IP/
# Hello from kv-agw-vm-1 — backend behind Application Gateway # load balanced!
# Confirm both backends are Healthy from the gateway's own point of view:
az network application-gateway show-backend-health \
--name kv-agw-agw --resource-group kv-agw-rg \
--query "backendAddressPools[].backendHttpSettingsCollection[].servers[].health" -o tsv
# Healthy
# Healthy
Healthy from show-backend-health is the single most useful signal on this whole stack: it is the gateway telling you the probe is passing. If instead you see Unhealthy and a 502 in the browser, jump to troubleshooting — that command’s healthProbeLog will name the exact reason.
The verification checklist:
| Step | Command | Expect |
|---|---|---|
| Public IP resolved | terraform output -raw gateway_public_ip |
An IP like 20.x.x.x |
| Endpoint serves | curl -k https://$IP/ |
The nginx “Hello from…” page |
| Load balancing works | repeat curl | Hostname alternates vm-0 / vm-1 |
| Backends healthy | az network application-gateway show-backend-health … |
Healthy for each server |
| TLS from Key Vault | curl -kv https://$IP/ 2>&1 | grep subject |
CN=app.kloudvin.dev |
12. Destroy. ⚠️ Do this — the gateway bills by the hour.
terraform destroy -auto-approve
# ... azurerm_application_gateway.web: Destruction complete after 6m2s
# Destroy complete! Resources: 18 destroyed.
Confirm the resource group is gone (az group show -n kv-agw-rg should error ResourceGroupNotFound). If a Key Vault lingers due to soft-delete, purge it with az keyvault purge --name <name> so a re-apply doesn’t collide on the name.
Variables, outputs & making it reusable
The demo hard-codes one listener, one pool and one routing rule. Real gateways host several sites with several path rules, and copy-pasting blocks is how they rot. Two Terraform patterns turn this into a reusable module: for_each over a map of sites, and dynamic blocks to generate repeated inner blocks from a variable.
Model the sites as a variable and let dynamic blocks expand them:
variable "sites" {
description = "Map of hostname => backend FQDNs to route to."
type = map(object({
hostname = string
backend_fqdns = list(string)
}))
default = {
app = { hostname = "app.kloudvin.dev", backend_fqdns = ["app-vmss.internal"] }
api = { hostname = "api.kloudvin.dev", backend_fqdns = ["api-vmss.internal"] }
}
}
# Inside azurerm_application_gateway:
dynamic "backend_address_pool" {
for_each = var.sites
content {
name = "${backend_address_pool.key}-pool"
fqdns = backend_address_pool.value.backend_fqdns
}
}
dynamic "http_listener" {
for_each = var.sites
content {
name = "${http_listener.key}-listener"
frontend_ip_configuration_name = "public-fe"
frontend_port_name = "https"
protocol = "Https"
ssl_certificate_name = "listener-cert"
host_name = http_listener.value.hostname
require_sni = true
}
}
Adding a site is then a one-line map entry, not a block copy. The variables worth exposing on such a module:
| Variable | Type | Why expose it |
|---|---|---|
location / prefix |
string | Stamp per-environment |
sku_tier |
string | Standard_v2 vs WAF_v2 per env (skip WAF in dev) |
min_capacity / max_capacity |
number | Autoscale bounds per env |
waf_mode |
string | Detection in staging, Prevention in prod |
sites |
map(object) | The routing table, data-driven |
key_vault_secret_id |
string | Point at the env’s cert |
subnet_id |
string | Bring-your-own network |
Before you write your own module, know the registry equivalents — for a standard gateway the community modules are mature and worth reading even if you fork them:
| Need | Registry module | Roll your own when |
|---|---|---|
| Application Gateway | Azure/avm-res-network-applicationgateway/azurerm (Azure Verified Module) |
Highly custom routing/WAF |
| Load Balancer | Azure/avm-res-network-loadbalancer/azurerm |
Unusual outbound/NAT topology |
| Full network | Azure/avm-res-network-virtualnetwork/azurerm |
— |
The Azure Verified Modules (AVM) program is Microsoft’s own maintained set — prefer those over abandoned third-party modules. Use a registry module when your gateway is conventional and you value the maintenance; roll your own when your routing, WAF exclusions or backend wiring are genuinely bespoke, which for gateways is common enough that many teams do end up with a thin in-house module. Either way, pin the version (version = "~> 0.x") — a floating module version is as dangerous as a floating provider.
Common mistakes and troubleshooting
The Application Gateway’s signature error is 502 Bad Gateway, and it is worth stating the core truth once: a 502 from Application Gateway almost never means the gateway is broken — it means the gateway could not get a good answer from a backend. The gateway is reporting a failure it observed downstream. So every 502 investigation starts at the same place — az network application-gateway show-backend-health — and fans out from there. This is the symptom → cause → fix table to keep open during an incident:
| Symptom | Likely cause | Fix |
|---|---|---|
502, backend health Unhealthy |
Probe path returns non-2xx (404/500) or wrong port | Point probe.path at a real 200 URL; match port/protocol in backend_http_settings |
502, health Unknown |
NSG/UDR blocks gateway subnet → backend | Allow the gateway subnet inbound on the backend port; check route tables |
| 502 intermittently under load | request_timeout too low for a slow backend |
Raise request_timeout; fix backend latency |
| Listener shows Unknown, HTTPS fails | Identity can’t read the Key Vault cert | Grant the UA identity Key Vault Secrets User; ensure identity{} is set |
apply fails: subnet in use / not empty |
Gateway subnet shares with other resources | Give the gateway its own dedicated subnet |
apply fails: rule needs priority |
v2 routing rule missing priority |
Add a unique priority to every request_routing_rule |
| Gateway won’t provision, stuck | v2 management ports 65200–65535 blocked | Allow GatewayManager inbound on the gateway subnet NSG |
| WAF returns 403 on valid requests | Prevention mode + OWASP false positive | Move to Detection, read firewall log, add rule exclusion, re-enable |
| Public IP rejected at plan/apply | v2 needs Standard + Static IP | sku = "Standard", allocation_method = "Static" |
| Backend gets wrong Host header | pick_host_name_from_backend_address / host_name misused |
Set the Host the backend app expects |
| Cert rotates but gateway serves old one | Referenced a versioned secret ID | Reference the versionless secret_id so KV rotation flows through |
| SNAT/outbound fails on the L4 LB | Standard LB is deny-by-default outbound | Add an azurerm_lb_outbound_rule (or NAT Gateway) |
Because 502 is the workhorse failure, here is the decision matrix that maps what you observe to the specific misconfiguration — walk it top to bottom:
| Backend health says | Meaning | Where the bug is |
|---|---|---|
Healthy but browser still 502 |
Backend flaked after the probe, or timeout hit | request_timeout, backend stability, app 5xx |
Unhealthy |
Probe reaches backend but gets a bad answer | Probe path/port/protocol, or app returns non-2xx |
Unknown |
Probe can’t reach the backend at all | NSG on backend subnet, UDR/route table, backend down |
| Empty / no servers | Pool has no members | ip_addresses/fqdns empty; VMSS not attached |
Beyond 502, the gnarliest real-world traps:
The dedicated-subnet rule. Application Gateway must have a subnet to itself. Putting anything else — a VM NIC, another service — in the gateway’s subnet fails the apply with a confusing “subnet is in use” error. Model it as its own azurerm_subnet and never associate other resources with it. Size it /24 to leave room for autoscale and future v2 capacity.
The management-port rule (v2). A WAF_v2/Standard_v2 gateway needs inbound access on ports 65200–65535 from the GatewayManager service tag, plus AzureLoadBalancer, or the control plane cannot manage it and provisioning hangs or the gateway shows an unhealthy control state. If you attach an NSG to the gateway subnet you must add those allow rules yourself.
The Key Vault cert chain. A listener whose cert comes from Key Vault fails silently — the listener shows Unknown and requests 502 at the TLS layer — when any of three things is wrong: the gateway’s managed identity isn’t assigned in the identity{} block, that identity lacks get on secrets (Key Vault Secrets User under RBAC), or the certificate in Key Vault is missing its intermediate chain. Reference the versionless secret ID so a rotation in Key Vault is picked up without a Terraform apply; reference a versioned ID and you pin to one cert version forever.
SKU/capacity confusion. A 502 or 503 under load can simply be an under-provisioned gateway. With autoscale_configuration, a min_capacity of 2 gives HA but a burst above your max_capacity throttles; watch the gateway’s Compute Units and Capacity Units metrics and raise the ceiling. Don’t confuse this L4/L7 capacity story with the App Service scaling story — if your backend is App Service, a 502 might originate there, and the App Service 502/503 troubleshooting playbook is the companion read.
Auth and permissions. The provider itself needs rights: az login must land on a subscription where you can create gateways, VMs and role assignments — and creating those azurerm_role_assignment resources requires Microsoft.Authorization/roleAssignments/write (i.e. Owner or User Access Administrator), which many contributors lack. A AuthorizationFailed on the role assignment, not the gateway, is the usual first wall.
Cost, cleanup & production notes
The economics are dominated by the gateway. A WAF_v2 bills a fixed gateway hour plus capacity units (a blend of compute, connections and throughput) — even idle at min_capacity = 2 it runs continuously. The L4 Load Balancer is far cheaper. Indicative India South, pay-as-you-go, July 2026:
| Resource | Rough monthly if left up | Notes |
|---|---|---|
| Application Gateway WAF_v2 | ~₹18,000 + capacity units (~$220+) | Fixed hour + CU; the big line |
| Standard Load Balancer | ~₹150 base + per-rule (~$2) | An order of magnitude cheaper |
| Standard public IP | ~₹300 (~$3.60) each | One per frontend |
2× Standard_B1s VM |
~₹2,800 (~$34) | The demo backend |
| Key Vault | ~₹0 + tiny per-operation | Certificate ops negligible |
| This demo, one week | ~₹5,000 (~$60) | Which is why you destroy it |
Cleanup is terraform destroy — but two gotchas: a soft-deleted Key Vault can block a same-name re-apply (az keyvault purge), and a public IP occasionally errors on delete if a dependency lingers (re-run destroy). Always confirm with az group show -n <rg> returning NotFound.
Production hardening, the five that matter:
- Remote, locked state. The
backend "azurerm"block (Storage Account + blob lease) shown inversions.tfis non-negotiable for a team — local state on a load-balancing tier is how two engineers clobber each other’s gateway. - Least privilege for the gateway identity. The managed identity should have exactly
Key Vault Secrets Useron exactly the one Key Vault — never a broad role. And the Terraform principal should get role-assignment rights only where it needs them. - WAF in Prevention, tuned. Ship Detection, watch the firewall log in Log Analytics, add targeted rule exclusions for legitimate false positives, then move to Prevention. A WAF in Detection forever is theatre.
- Tag and diagnostic-log everything. Tag every resource (
project,owner,env) for cost attribution, and send the gateway’sApplicationGatewayAccessLog,PerformanceLogandFirewallLogto Log Analytics — the firewall log is where you tune the WAF, and the access log is where you debug 502s post-hoc. - Watch drift. Someone will “quickly” add a listener in the portal. Run
terraform planon a schedule (or a drift-detection pipeline) so config that drifts from code is caught, not discovered during an incident.
Cheat-sheet
The dense reference for this tier — resources, the arguments you reach for most, and the verification commands:
| Resource | Purpose | Must-set arguments |
|---|---|---|
azurerm_lb |
L4 load balancer | sku = "Standard", frontend_ip_configuration |
azurerm_lb_backend_address_pool |
L4 target set | loadbalancer_id |
azurerm_lb_probe |
L4 health check | protocol, port, request_path (Http) |
azurerm_lb_rule |
L4 port mapping | frontend_port, backend_port, probe_id |
azurerm_lb_outbound_rule |
Deterministic SNAT | backend_address_pool_id, allocated_outbound_ports |
azurerm_lb_nat_rule |
Port → one instance | frontend_port, backend_port |
azurerm_application_gateway |
L7 gateway | sku (WAF_v2), the inner blocks below |
azurerm_web_application_firewall_policy |
WAF rules | policy_settings.mode, managed_rules |
| Application Gateway inner block | Sets |
|---|---|
gateway_ip_configuration |
The dedicated subnet |
frontend_ip_configuration + frontend_port |
Public IP + port |
http_listener |
Entry point (proto, cert, host) |
ssl_certificate |
key_vault_secret_id for TLS |
backend_address_pool |
ip_addresses / fqdns |
backend_http_settings |
port, protocol, probe_name |
probe |
path, interval, match.status_code |
request_routing_rule |
rule_type, priority |
url_path_map |
Path-based routing |
| Verify with | Command |
|---|---|
| Backend health | az network application-gateway show-backend-health -n <gw> -g <rg> |
| Curl the endpoint | curl -k https://$(terraform output -raw gateway_public_ip)/ |
| List rules | az network application-gateway rule list -g <rg> --gateway-name <gw> -o table |
| WAF policy | az network application-gateway waf-policy show -n <pol> -g <rg> |
| Effective NSG | az network nic list-effective-nsg … |
Interview and exam questions
1. When would you choose Azure Load Balancer over Application Gateway? For raw TCP/UDP traffic, internal tiers, or when you need the lowest latency and cheapest bill and do not need to read the HTTP request. Application Gateway is for HTTP/S that needs URL routing, TLS termination, or a WAF.
2. Why does Application Gateway need a dedicated subnet? The gateway’s instances consume the subnet exclusively for their internal management and data plane; Azure forbids other resources in it and the apply fails with a “subnet in use” error otherwise. Size it /24 for autoscale headroom.
3. A WAF_v2 gateway returns 502 for every request. Walk me through diagnosis. Run az network application-gateway show-backend-health. Unhealthy → the probe reaches the backend but gets a bad answer (wrong path/port, app returning 5xx). Unknown → the probe can’t reach the backend at all (NSG on the backend subnet, a route table, or the backend is down). Healthy but still 502 → a request timeout or the backend flaking after the probe. Fix at the layer the health state points to; never just scale the SKU.
4. What’s the difference between WAF Detection and Prevention mode? Detection logs a rule match but forwards the request; Prevention blocks it (403). Best practice: onboard in Detection, tune out false positives from the firewall log, then switch to Prevention.
5. How do you give an Application Gateway listener a TLS certificate without putting a secret in Terraform state? Store the cert in Key Vault, reference it from ssl_certificate.key_vault_secret_id, and give the gateway a user-assigned managed identity with Key Vault Secrets User. The gateway reads the cert at runtime; nothing sensitive lands in state. Use the versionless secret ID so KV rotation flows through without an apply.
6. Basic vs multi-site listener? A Basic listener answers all traffic on its frontend IP+port; a multi-site listener is scoped to one or more hostnames (via SNI on HTTPS), letting many sites share one IP and port.
7. What does disable_outbound_snat do on an azurerm_lb_rule, and why set it? It turns off the rule’s implicit outbound SNAT so you can define egress explicitly with an azurerm_lb_outbound_rule (or a NAT Gateway). Explicit outbound gives deterministic port allocation and avoids SNAT port exhaustion under load.
8. How does a VM Scale Set attach to a load balancer or Application Gateway? Inside the scale set’s network_interface.ip_configuration, set load_balancer_backend_address_pool_ids (L4) or application_gateway_backend_address_pool_ids (L7). The scale set self-registers every instance into the pool — you never manage members individually.
9. (Terraform Associate 003) The Application Gateway pool references azurerm_linux_virtual_machine.web[*].private_ip_address. What guarantees the VMs are created before the gateway? Terraform’s implicit dependency graph: because the gateway resource references the VM attributes, Terraform orders VM creation first automatically. No depends_on is needed for that edge — it’s only needed for hidden dependencies with no attribute reference (like the Key Vault role assignment here).
10. (Terraform Associate 003) You changed only min_capacity from 2 to 3. What does terraform plan show, and will it replace the gateway? An in-place update (~) to the autoscale_configuration, not a replacement — capacity is a mutable property. Plan shows 0 to add, 1 to change, 0 to destroy. (A change to something immutable, like the SKU tier v1↔v2, would force replacement.)
11. Why pin azurerm with ~> 4.0 and use a remote backend for this stack? A floating provider version means a later apply can change resource behaviour between two green runs; ~> pins the major/minor. A remote, locked backend (Storage Account + blob lease) prevents two engineers from corrupting the state of a shared load-balancing tier.
12. What’s the global alternative to Application Gateway, and when do you compose them? Azure Front Door — a global L7 edge service with anycast, edge TLS, CDN caching and a global WAF. You compose Front Door → Application Gateway when you want a global edge (caching + cross-region failover) in front of a regional gateway that does fine-grained in-VNet routing; you then lock the gateway listener to accept only Front Door traffic.
Key takeaways
- Layer first, service second. L4 (
azurerm_lb) forwards TCP/UDP and never reads the request; L7 (azurerm_application_gateway) is a reverse proxy that reads URLs, terminates TLS and runs a WAF. Pick the layer, then the service; Front Door is the global L7 option. - Always Standard / v2. Standard Load Balancer and
Standard_v2/WAF_v2gateways are the only sane choices for new builds — the Basic and v1 SKUs are deprecated, unzoned and can’t autoscale. - The probe is the point. Both load balancers exist to route around dead instances; a probe with the wrong path or port silently marks every backend unhealthy and yields a 502. Make the probe hit a real 200 URL.
- 502 = backend, probe, or NSG — not the gateway. Start every 502 at
show-backend-health;Unhealthyis a probe/app problem,Unknownis a network (NSG/route) problem,Healthy-but-502 is a timeout. - TLS from Key Vault, not from state. Reference the listener cert by
key_vault_secret_idvia a managed identity withKey Vault Secrets User; use the versionless ID so rotation needs no apply. - WAF: Detection → tune → Prevention. Ship in Detection, remove false positives from the firewall log, then enforce with Prevention. Use the standalone
azurerm_web_application_firewall_policy, not the legacy inline block. - Give the gateway its own subnet and its management ports. A dedicated
/24subnet and inboundGatewayManager65200–65535 are non-negotiable for a v2 gateway to provision. - Build it, verify it, destroy it. A WAF_v2 gateway bills by the hour even idle —
terraform destroyis part of the exercise, not an afterthought.