AppGW to Internal Azure Container Apps NSG Mismatch Reproduction Lab¶
Reproduce H1 from the AppGW to Internal Container Apps: NSG Destination Pinned to staticIp Fails playbook: an Application Gateway backend health goes Unhealthy against an internal Container Apps environment because the container app subnet NSG inbound rule uses Destination = staticIp (a single IP) instead of the container app's subnet CIDR. NSGs behind an internal load balancer evaluate the destination NIC, not the load balancer frontend, so staticIp as a destination is by-design broken on workload profiles environments.
Lab Metadata¶
| Attribute | Value |
|---|---|
| Difficulty | Intermediate |
| Estimated Duration | 20-30 minutes (8-12 min for the initial Bicep deploy, 5 min for trigger.sh, 3 min for fix.sh) |
| Tier | Workload profiles (Consumption) |
| Failure Mode | Application Gateway backend Unhealthy; client requests return HTTP 502 (or HTTP 000 client timeout if AppGW's request timeout exceeds the client --max-time) — see Client-side evidence for the manifestation observed on the current committed evidence pack |
| Skills Practiced | Application Gateway backend health, NSG rule inspection, Private DNS Zone linkage, single-variable failure isolation |
Evidence depth
This lab is fully reproducible with Bicep-based infrastructure-as-code and helper scripts under labs/appgw-to-internal-aca-nsg-mismatch/:
infra/main.bicep+infra/dns-and-appgw.bicepprovision one VNet with two subnets (snet-appgw/24,snet-cae/23delegated toMicrosoft.App/environments), one Log Analytics workspace, one internal Container Apps environment (vnetConfiguration.internal = true, Consumption workload profile), one sample Container App runningmcr.microsoft.com/azuredocs/containerapps-helloworld:latest, a linked Private DNS Zone matchingenv.defaultDomainwith wildcard and apex A records pointing toenv.staticIp, and one Application Gateway Standard_v2 with a backend pool targeting the container app FQDN and an HTTPS custom probe (pickHostNameFromBackendHttpSettings = true, match200-399). The DNS and AppGW resources are in a nested module soenv.defaultDomainandenv.staticIpare resolvable when the Private DNS Zone name is evaluated (workaround for BCP120).trigger.shreads the deployment outputs, waits 120 s for the baseline backend to converge, captures baseline evidence, then applies a 3-rule NSG misconfiguration on the container app subnet: rule 100 Allow (Source = snet-appgw CIDR,Destination = <staticIp>/32— the H1 misconfig, ports443+31443), rule 200 Allow (Source = AzureLoadBalancer,Destination = snet-cae CIDR, ports30000-32767), rule 4096 Deny (all). Rules 200 and 4096 make the NSG a realistically locked-down production shape so that the defaultAllowVnetInBound(priority 65000) does not mask rule 100's Destination misconfiguration. Waits 150 s and captures broken evidence.fix.shrewrites rule 100 Destination from<staticIp>/32to the CAE subnet CIDR (all other rule properties unchanged), waits 150 s, and captures fixed evidence. This preserves the "single controlled variable" property of the experiment: only rule 100's Destination address ever differs between broken and fixed states.verify.shis a pure file processor — it reads baseline / broken / fixed JSON blobs inevidence/and emitsverify-result.jsonwith seven gates (A/B/C for H1 confirmation, D/E for falsification, F/G for H2 and H3 exclusion) plus a verdict (HYPOTHESIS_CONFIRMED/HYPOTHESIS_NOT_CONFIRMED) and a falsification status (NOT_YET_TESTED/FIX_VERIFIED/FIX_DID_NOT_RECOVER). It does not call Azure and does not depend on$RG.evidence/carries the committed2026-07-04evidence pack captured on the re-executed live run in Korea Central; seelabs/appgw-to-internal-aca-nsg-mismatch/evidence/README.mdfor the per-file provenance and PII sanitization notes. Every artifact can also be regenerated on demand by re-runningtrigger.shandfix.shagainst a fresh deployment.
Live-executed on 2026-07-03 (initial validation) and 2026-07-04 (re-execution + Portal screenshots)
On 2026-07-03 the full lab was first executed end-to-end against a live Azure subscription in Korea Central (azure-cli 2.79.0). This initial validation surfaced a CAE FQDN routing quirk: with external: false on an internal environment, the app FQDN is emitted as <app>.internal.<env>.<region>.azurecontainerapps.io and is reachable ONLY through the internal service mesh (100.100.x.x), NOT through the ILB staticIp; AppGW backend probes therefore got HTTP 404 "This Container App is stopped or does not exist" from the CAE edge-proxy. The fix (infra/main.bicep line ~200) is to set external: true on the ingress even for internal environments — this preserves the internal-ILB-only exposure at the network layer while surfacing the app FQDN in the ILB-reachable form. The Bicep template in this repository carries the fix; if you deploy an earlier commit you will hit this trap. On 2026-07-04 the lab was re-executed in the same Korea Central subscription against a fresh deployment (agw-appgwnsg-kl4kt7, nsg-snet-cae-kl4kt7), and the baseline / broken / fixed evidence files under labs/appgw-to-internal-aca-nsg-mismatch/evidence/ are the captured artifacts from this re-execution. verify.sh emitted evidence/verify-result.json with all seven gates true, verdict = HYPOTHESIS_CONFIRMED, and falsification = FIX_VERIFIED (exit 0). During the same 2026-07-04 re-execution, a Playwright MCP browser session against ms.portal.azure.com captured the five Portal screenshots referenced under ## 6) Portal Evidence live from the running deployment. Each PNG was sanitized per the AGENTS.md PII rules (GUID → all-zero placeholder, MCAPS-* → Visual Studio Enterprise Subscription, Microsoft Non-Production → Contoso, employee alias/email/display-name replaced, avatar masked with Portal-blue #0078d4) and visually verified via look_at/Read before commit.
1) Background¶
An internal Azure Container Apps environment (vnetConfiguration.internal = true) exposes container apps only through the environment's internal load balancer (ILB). The ILB frontend IP is surfaced as environment.properties.staticIp and is inside the container app's infrastructure subnet CIDR. When an operator fronts the internal environment with an Application Gateway (AppGW), the AppGW must be able to reach the container app via the ILB, which in turn forwards traffic to the edge-proxy NIC inside the container app subnet (on ports 31443 for HTTPS and 31080 for HTTP).
For the subnet NSG to allow this traffic, the Microsoft Learn firewall-integration NSG table for workload profiles prescribes:
- Source = the Application Gateway subnet CIDR (or the client IP range).
- Destination = the container app's subnet CIDR (not
staticIp). - Destination ports =
443and31443(add80and31080for HTTP).
The Destination requirement is the failure surface this lab reproduces. Operators frequently pin the NSG rule Destination to staticIp because that is the address AppGW ends up sending traffic to (after resolving the container app FQDN through the linked Private DNS Zone) and the address written into the Private DNS Zone A record. But NSGs behind a load-balanced pool do not evaluate destination against the load balancer frontend — they evaluate against the destination NIC (the edge-proxy NIC in snet-cae, whose IP is inside the subnet CIDR but is not equal to staticIp). A rule with Destination = <staticIp>/32 therefore drops every packet that lands on the edge-proxy NIC, and AppGW backend health goes Unhealthy.
This class of misconfiguration is especially common:
- After migrating from a Consumption-only environment to workload profiles (the old NSG table listed
staticIpas an acceptable Destination, but the workload-profiles table lists only the subnet CIDR). - When operators copy-paste NSG rules from unrelated AppGW tutorials that target public backends where
staticIpis the on-wire address. - When infrastructure-as-code templates hard-code
env.properties.staticIpas the Destination because it is a stable, environment-scoped value that resolves at deployment time.
This lab isolates the failure to a single controlled variable: rule 100's Destination address. Ports (443+31443) are left correct, the ILB probe path (rule 200) is left correct, and no other rule changes between the broken and fixed states. If the lab shows AppGW backend health flipping Healthy → Unhealthy → Healthy in lock-step with the Destination toggle, H1 is falsifiably confirmed.
2) Hypothesis¶
IF the container app subnet NSG inbound rule 100 has Destination = <staticIp>/32 while all other rule properties (Source, ports, protocol, priority) are left correct AND the NSG also carries a Deny-all rule at priority 4096 so that the default AllowVnetInBound (priority 65000) cannot mask rule 100, THEN:
- Baseline state (before
trigger.sh, NSG has only Azure default rules): Application Gateway backend health reports every backend server asHealthy; acurlagainst the AppGW public IP returns HTTP 200. - Broken state (after
trigger.shapplies rules 100 + 200 + 4096): Application Gateway backend health transitions toUnhealthyon every backend server; acurlagainst the AppGW public IP returns HTTP 502; the container app subnet NSG rule 100 hasdestinationAddressPrefix = <staticIp>/32(ordestinationAddressPrefixes = [<staticIp>/32]). - Fixed state (after
fix.shrewrites rule 100 Destination from<staticIp>/32to the CAE subnet CIDR, all other properties unchanged): Application Gateway backend health returns toHealthyon every backend server; acurlagainst the AppGW public IP returns HTTP 200; rule 100 now reportsdestinationAddressPrefix = <caeSubnetPrefix>.
Falsification requires the fix to restore Healthy without changing any other variable — no port changes, no ILB probe rule changes, no AppGW backend or probe changes. If restoring Healthy requires any other edit, the H1 attribution to rule 100's Destination is not clean.
Architecture¶
flowchart TD
Client["Operator workstation<br/>(public internet)"] -->|HTTP :80| AppGW["Application Gateway Standard_v2<br/>snet-appgw 10.0.1.0/24"]
AppGW -->|"backend pool = app FQDN<br/>HTTPS :443, pickHostNameFromBackendAddress"| PDZ["Private DNS Zone<br/>= env.defaultDomain<br/>* and @ A records -> staticIp"]
PDZ -->|resolves to staticIp| ILB["Internal ILB frontend<br/>= env.properties.staticIp"]
ILB -->|forwards to :31443 on backend NIC| NSG{"NSG on snet-cae<br/>rule 100: Allow, Dst = ???, ports 443+31443<br/>rule 200: Allow, Src = AzureLoadBalancer, ports 30000-32767<br/>rule 4096: Deny all"}
NSG -->|"Dst = staticIp/32 (BROKEN)"| Drop["Packet dropped:<br/>edge-proxy NIC IP != staticIp<br/>-> AppGW backend Unhealthy"]
NSG -->|"Dst = snet-cae CIDR (FIXED)"| Edge["Edge-proxy NIC in snet-cae<br/>-> Container App replica<br/>-> AppGW backend Healthy"] | State | Rule 100 Destination | AppGW backend health | Client HTTP result |
|---|---|---|---|
| Baseline (empty NSG, defaults only) | n/a — no custom rule 100 exists | Healthy | 200 |
Broken (trigger.sh) | <staticIp>/32 | Unhealthy | 502 or 000 (client timeout) |
Fixed (fix.sh) | 10.0.2.0/23 (CAE subnet CIDR) | Healthy | 200 |
3) Runbook¶
Deploy infrastructure¶
export RG="rg-appgw-aca-nsg-lab"
export LOCATION="koreacentral"
export BASE_NAME="appgwnsg"
az group create --name "$RG" --location "$LOCATION"
az deployment group create \
--resource-group "$RG" --name appgw-aca-nsg-mismatch \
--template-file labs/appgw-to-internal-aca-nsg-mismatch/infra/main.bicep \
--parameters baseName="$BASE_NAME" location="$LOCATION"
| Command | Why it is used |
|---|---|
az group create | Creates the resource group that scopes all lab resources. |
az deployment group create | Deploys infra/main.bicep, which provisions the VNet, subnets, Log Analytics workspace, internal Container Apps environment, sample Container App, Private DNS Zone (via nested module), and Application Gateway. Takes roughly 8-12 minutes; Application Gateway Standard_v2 dominates the wall clock. |
Reproduce the failure¶
bash labs/appgw-to-internal-aca-nsg-mismatch/trigger.sh
bash labs/appgw-to-internal-aca-nsg-mismatch/verify.sh
| Command | Why it is used |
|---|---|
trigger.sh | Reads deployment outputs, waits 120 s for AppGW backend to converge to Healthy (baseline), captures baseline evidence (backend health, NSG rules, curl), then adds the 3-rule NSG misconfiguration (rule 100 Allow with Destination = <staticIp>/32, rule 200 Allow for AzureLoadBalancer, rule 4096 Deny all), waits 150 s for probe re-convergence, and captures broken evidence. |
verify.sh | Reads the baseline and broken JSON blobs in evidence/ and emits evidence/verify-result.json with gates A, B, C (H1 confirmation), F, and G (H2 and H3 exclusion) evaluated. Exits non-zero if the verdict is not HYPOTHESIS_CONFIRMED or if gate F or G is false on the broken snapshot. |
Apply the fix¶
bash labs/appgw-to-internal-aca-nsg-mismatch/fix.sh
bash labs/appgw-to-internal-aca-nsg-mismatch/verify.sh
| Command | Why it is used |
|---|---|
fix.sh | Rewrites NSG rule 100 Destination from <staticIp>/32 to the CAE subnet CIDR (all other rule properties preserved), waits 150 s for probe re-convergence, captures fixed evidence. |
verify.sh | Re-reads the evidence directory (now including fixed blobs) and re-emits verify-result.json with gates D and E evaluated and falsification = FIX_VERIFIED if the backend returns to Healthy, rule 100 Destination is now the CAE CIDR, and gates F and G were true on the broken snapshot. Exits non-zero if falsification is not FIX_VERIFIED. |
Clean up¶
| Command | Why it is used |
|---|---|
cleanup.sh | Deletes the resource group and all child resources (async). Application Gateway Standard_v2 dominates the hourly cost of this lab, so always run cleanup.sh immediately after capturing evidence. |
4) Experiment Log¶
The per-scenario observation log from the 2026-07-04 re-executed live run in Korea Central (the currently committed evidence pack). The initial 2026-07-03 live run surfaced the CAE FQDN routing quirk documented in the success admonition above; the 2026-07-04 re-execution captured the artifact set now in-tree. Every [Observed] bullet cites the raw artifact under labs/appgw-to-internal-aca-nsg-mismatch/evidence/ captured during the 2026-07-04 re-execution.
Baseline scenario (before trigger.sh)¶
- [Observed]
evidence/baseline-backend-health.json:.backendAddressPools[0].backendHttpSettingsCollection[0].servers[0].health = "Healthy". - [Observed]
evidence/baseline-nsg-rules.json: empty custom rule set (only Azure default rules are in effect —AllowVnetInBoundpriority 65000,AllowAzureLoadBalancerInBoundpriority 65001,DenyAllInBoundpriority 65500 — implicit and not enumerated inaz network nsg rule listoutput). - [Observed]
evidence/baseline-curl.txt:HTTP 200 in 0.275887s.
Broken scenario (after trigger.sh)¶
- [Observed]
evidence/broken-backend-health.json:.backendAddressPools[0].backendHttpSettingsCollection[0].servers[0].health = "Unhealthy"with probe error textCannot connect to backend server. Check whether any NSG/UDR/Firewall is blocking access to the server. Check if application is running on correct port. To learn more visit - https://aka.ms/servernotreachable.This is the strongest single-string signal that the failure is at the NSG/UDR/Firewall layer, not at the container-app-runtime layer. - [Observed]
evidence/broken-nsg-rules.json: rule 100 (allow-appgw-inbound-broken) withsourceAddressPrefix = "10.0.1.0/24",destinationAddressPrefix = "10.0.2.243/32"(thestaticIp/32 misconfig —10.0.2.243is theenvironmentStaticIpfromdeploy-outputs.json, inside the CAE subnet CIDR10.0.2.0/23),destinationPortRanges = ["443","31443"]; rule 200 (allow-azure-lb-probes) withsourceAddressPrefix = "AzureLoadBalancer",destinationAddressPrefix = "10.0.2.0/23",destinationPortRange = "30000-32767"; rule 4096 (deny-all-inbound) with all*. - [Observed]
evidence/broken-curl.txt:HTTP 502 in 0.325699s— AppGW returned HTTP 502 within ~0.3 s of the client request, matching the original hypothesis prediction. AppGW Standard_v2 short-circuited to aCannot connect to backend serverresponse because its backend health had already transitioned toUnhealthybefore this request arrived (rule 100 misconfig had been applied 150 s earlier bytrigger.shand probe re-convergence had already flipped every backend server toUnhealthy). In an alternative shape — for example if AppGW's request timeout exceeded the client's--max-time, or if backend health had not yet re-converged at the moment of the request — the manifestation could shift to HTTP 000 (client timeout withcurl: (28) Operation timed out); both HTTP 502 and HTTP 000 are consistent with the same underlying H1 root cause because both mean the client did not receive a valid successful HTTP response. - [Correlated] The transition from
Healthy(baseline) toUnhealthy(broken) was observed within one AppGW probe re-convergence window aftertrigger.shapplied rule 100 (thetrigger.shscript waits 150 s for probe reconvergence and then captures evidence, and the health-status flip was already stable at that capture point). No other variable changed between the two scenarios.
Fixed scenario (after fix.sh)¶
- [Observed]
evidence/fixed-backend-health.json:.backendAddressPools[0].backendHttpSettingsCollection[0].servers[0].health = "Healthy". - [Observed]
evidence/fixed-nsg-rules.json: rule 100 withdestinationAddressPrefix = "10.0.2.0/23"(the CAE subnet CIDR fromdeploy-outputs.json .caeSubnetPrefix.value); all other rule 100 properties (sourceAddressPrefix = "10.0.1.0/24",destinationPortRanges = ["443","31443"],priority = 100,access = Allow, protocol) unchanged from the broken scenario. - [Observed]
evidence/fixed-curl.txt:HTTP 200 in 0.376139s— restored to the same latency band as baseline (~0.3-0.4 s). - [Correlated] Falsification (post-fix evidence): the fix restored
Healthywithout changing any other variable. Ports (443+31443) were left correct in both broken and fixed states (gate F), and rule 200 kept theAzureLoadBalancer → snet-cae, 30000-32767allow path present in both states (gate G). These two invariants exclude H2 (missing edge-proxy ports) and H3 (AllowAzureLoadBalancerInBoundshadowed by a higher-priority Deny) as compounding causes.
Summary¶
- [Inferred] If gates A + B + C are all
trueat post-trigger.shverification, and gates D + E + F + G are alltrueat post-fix.shverification, then H1 (NSG rule 100 Destination pinned tostaticIp/32) is the sole cause of the AppGW backendUnhealthystate. Bothverdict = HYPOTHESIS_CONFIRMEDandfalsification = FIX_VERIFIEDshould appear inevidence/verify-result.json. - [Observed] On the
2026-07-04re-executed live run all seven gates evaluatedtruein the currently committedevidence/verify-result.json:verdict = HYPOTHESIS_CONFIRMED,falsification = FIX_VERIFIED,verify.shexit 0. H1 (NSG rule 100 Destination pinned tostaticIp/32) is confirmed as the sole cause of the AppGW backendUnhealthystate on this deployment shape. The2026-07-03initial run also had all seven gates true after the CAE FQDN routing quirk fix was applied; the2026-07-04re-execution reproduces the same result against a fresh deployment.
Expected Evidence¶
verify.sh emits evidence/verify-result.json with the following seven gates. The hypothesis is confirmed when gates A, B, and C are all true. Falsification of the hypothesis is closed when gates D and E are also true after fix.sh. Gates F and G actively exclude H2 (missing edge-proxy ports) and H3 (AllowAzureLoadBalancerInBound shadowed by a higher-priority Deny) as compounding causes; if F or G is false, the H1-only attribution weakens even if D and E are true.
| Gate | Evidence file | Confirmation rule | Falsification |
|---|---|---|---|
A: baseline backend Healthy | evidence/baseline-backend-health.json | Every .health value under backendAddressPools[].backendHttpSettingsCollection[].servers[] equals Healthy | Any server reports Unhealthy before trigger.sh applies the misconfig |
B: broken backend Unhealthy | evidence/broken-backend-health.json | At least one .health value equals Unhealthy after trigger.sh | All servers still report Healthy (misconfig was not applied, or the default AllowVnetInBound masked rule 100) |
C: broken rule 100 Destination = <staticIp>/32 | evidence/broken-nsg-rules.json | The rule with priority = 100 has destinationAddressPrefix or destinationAddressPrefixes[0] equal to <env.staticIp>/32 (or the bare address) | Rule 100 Destination is anything else — the misconfig was not applied |
D: fixed backend Healthy | evidence/fixed-backend-health.json | Every .health value equals Healthy after fix.sh | Any server still reports Unhealthy — the fix did not recover, so H1 is not the sole cause |
| E: fixed rule 100 Destination = CAE subnet CIDR | evidence/fixed-nsg-rules.json | Rule 100 Destination equals the value in deploy-outputs.json .caeSubnetPrefix.value (e.g. 10.0.2.0/23) | Rule 100 Destination is not the CAE CIDR — the fix was not applied |
| F: broken rule 100 ports intact (H2 exclusion) | evidence/broken-nsg-rules.json | Rule 100's port list (destinationPortRange or destinationPortRanges[]) contains both 443 and 31443 | Ports missing — H2 (missing edge-proxy ports) becomes a compounding failure driver and the single-variable claim collapses |
| G: broken rule 200 shape intact (H3 exclusion) | evidence/broken-nsg-rules.json | A rule with sourceAddressPrefix == "AzureLoadBalancer", destinationAddressPrefix or destinationAddressPrefixes[0] equal to the CAE subnet CIDR, and priority strictly less than 4096 exists | Rule missing or ordered after the deny — H3 (ILB probe path shadowed by higher-priority Deny) becomes a compounding failure driver |
Verdict transitions:
- Before
trigger.shcompletes:verdict = HYPOTHESIS_NOT_CONFIRMED,falsification = NOT_YET_TESTED. - After
trigger.sh(gates A, B, C all true):verdict = HYPOTHESIS_CONFIRMED,falsification = NOT_YET_TESTED. Gates F and G also evaluate at this point and MUST be true — if either is false,verify.shexits non-zero because the H1-only attribution is compromised by an H2/H3 compounding cause. - After
fix.sh(gates D, E also true, F and G still true):verdict = HYPOTHESIS_CONFIRMED,falsification = FIX_VERIFIED. - If
fix.shruns but backend does not recover:verdict = HYPOTHESIS_CONFIRMED,falsification = FIX_DID_NOT_RECOVER, andverify.shexits non-zero — this outcome would invalidate the single-variable claim and require investigating H2 (missing edge-proxy ports, ruled out by gate F on the broken snapshot) or H3 (Deny rule shadowingAllowAzureLoadBalancerInBound, ruled out by gate G on the broken snapshot) as compounding causes.
Client-side evidence¶
trigger.sh and fix.sh also capture curl -w '%{http_code}' results against the AppGW public IP into baseline-curl.txt, broken-curl.txt, and fixed-curl.txt. Observed on the 2026-07-04 re-executed live run (the currently committed evidence/*-curl.txt):
baseline-curl.txt:HTTP 200 in 0.275887sbroken-curl.txt:HTTP 502 in 0.325699sfixed-curl.txt:HTTP 200 in 0.376139s
The observed broken-state manifestation on the currently committed run is HTTP 502 in ~0.3 s, matching the original hypothesis prediction. The lab treats HTTP 502 and HTTP 000 (client timeout) as equivalent evidence of the same underlying H1 failure (Cannot connect to backend server from AppGW backend health), because both mean the client did not receive a valid successful HTTP response. The exact status code depends on AppGW's request-timeout configuration relative to the client's --max-time and on the timing of the request relative to backend-health probe re-convergence; if AppGW's request timeout exceeds the client's --max-time, or if the request arrives before probe re-convergence has fully flipped every backend server to Unhealthy, the manifestation can shift to HTTP 000 (client timeout with curl: (28) Operation timed out), and both are consistent with H1.
The curl evidence is redundant with the AppGW backend health gates but provides an operator-friendly end-to-end signal that the failure is observable from an unprivileged client.
5) Verification Queries¶
The lab does not require KQL because the primary signals are AppGW control-plane state (backend health) and NSG rule state — both are read via az CLI, not Log Analytics. However, once the lab is deployed the following KQL queries against the AppGW diagnostic settings can supplement the CLI evidence.
KQL: AppGW access log 502 traces during the broken window¶
Replace the two datetime() placeholders with the wall-clock window between trigger.sh completing step 6 (broken evidence captured) and fix.sh starting step 1. trigger.sh prints its step-6 completion line to stdout; the operator can capture the start and end timestamps manually or from the deployment activity log. The TimeGenerated filter is required — without it, a shared Log Analytics workspace would return 502 rows from unrelated AppGW deployments and rows from a previous run of this lab, which would contradict the "zero such rows before trigger.sh and after fix.sh" claim below.
let brokenStart = datetime(2026-07-04T10:30:00Z);
let brokenEnd = datetime(2026-07-04T10:35:00Z);
AzureDiagnostics
| where TimeGenerated between (brokenStart .. brokenEnd)
| where ResourceType == "APPLICATIONGATEWAYS"
| where Category == "ApplicationGatewayAccessLog"
| where httpStatus_d == 502
| project TimeGenerated, clientIP_s, requestUri_s, httpStatus_d, backendPoolName_s, backendSettingName_s, serverStatus_s, serverResponseLatency_s
| order by TimeGenerated desc
Expected during the broken window: rows where httpStatus_d = 502, backendPoolName_s = aca-backend-pool, and serverStatus_s indicates the backend was unreachable. Re-run the same query with brokenStart and brokenEnd bracketing the baseline window (before trigger.sh) and the fixed window (after fix.sh) and expect zero rows in both.
KQL row count depends on the client-timeout manifestation
On the 2026-07-04 re-executed live run the client-side manifestation was HTTP 502 in ~0.3 s (see Client-side evidence), so this KQL would return httpStatus_d = 502 rows during the broken window. In alternative shapes where AppGW's request timeout exceeds the client's --max-time and the client sees HTTP 000 (client timeout) instead, AppGW may not write an httpStatus_d = 502 access-log row for that request even though the backend was Unhealthy throughout the window. To catch both manifestations, either drop the where httpStatus_d == 502 filter (and add | where httpStatus_d in (502, 0) or isnull(httpStatus_d)), or fall back to the BackendPoolName + HealthState fields in the Application Gateway Backend Health metric (via az monitor metrics list) which capture the Unhealthy state independent of whether any client request produced an access-log row.
KQL: AppGW backend health transitions¶
Application Gateway backend health transitions are not written to AzureDiagnostics on every probe cycle (only ApplicationGatewayAccessLog and ApplicationGatewayFirewallLog are surfaced). To trend backend health over time, use the az network application-gateway show-backend-health command on a schedule and archive the JSON, or use the HealthyHostCount and UnhealthyHostCount Application Gateway metrics via az monitor metrics list. The lab does not automate this — the three JSON snapshots (baseline / broken / fixed) captured by trigger.sh and fix.sh are sufficient for the H1 falsification argument.
6) Portal Evidence¶
Azure Portal screenshots for the five key states of this lab. Saved under docs/assets/troubleshooting/appgw-to-internal-aca-nsg-mismatch/. Captured 2026-07-04 against a live re-deployment in Korea Central (agw-appgwnsg-kl4kt7, nsg-snet-cae-kl4kt7) via a Playwright MCP session on ms.portal.azure.com. Each PNG was sanitized per the AGENTS.md PII rules and visually verified before commit; the az CLI JSON files under evidence/ (baseline-backend-health.json, broken-backend-health.json, fixed-backend-health.json, broken-nsg-rules.json, fixed-nsg-rules.json) remain the authoritative machine-readable evidence for gates A-G and are what verify.sh reads. The Portal screenshots below are the operator-facing complement — they show the same failure and recovery in the shape a support engineer would see when triaging the incident from the Portal.
Baseline — Application Gateway Backend health¶

Portal evidence captured — Application Gateway → Backend health (baseline, before trigger.sh)
Every backend server in aca-backend-pool reports Healthy (green check, Healthy: 1 out of 1). The details column shows Success. Received 200 status code, confirming the AppGW custom probe against the Container Apps edge-proxy is returning HTTP 200. This is the positive-control screenshot that proves the deployment is in a working state before trigger.sh applies the misconfig.
Look for: green Healthy status, 1 out of 1 count, Received 200 status code in the details cell.
Capture path: Application Gateway (agw-appgwnsg-kl4kt7) → Backend health → aca-backend-pool.
Broken — Application Gateway Backend health¶

Portal evidence captured — Application Gateway → Backend health (after trigger.sh)
Every backend server has transitioned to Unhealthy (red X, Unhealthy: 1 out of 1). The details column reports Cannot connect to backend server. Check whether any NSG/UDR/Firewall is blocking access... — this is the exact error string the AppGW surface for an NSG-blocked probe, and is the H1 failure signature.
Look for: red Unhealthy status, 1 out of 1 count, Cannot connect to backend server + NSG/UDR/Firewall in the details cell.
Capture path: Application Gateway (agw-appgwnsg-kl4kt7) → Backend health → aca-backend-pool.
Broken — NSG rule 100 Destination¶

Portal evidence captured — NSG → Overview (inbound security rules table, after trigger.sh)
Rule allow-appgw-inbound-broken at priority 100 shows Destination = 10.0.2.243/32 — the environment's staticIp rendered as a single-address prefix — instead of the CAE subnet CIDR 10.0.2.0/23. Rule 200 (allow-azure-lb-probes: source AzureLoadBalancer, dst 10.0.2.0/23, ports 30000-32767) and rule 4096 (deny-all-inbound: any/any Deny) are also visible, proving the NSG shape is realistically locked down and the failure cannot be attributed to a missing ILB probe allow or an incidental default-rule bypass.
Look for: rule 100 Destination cell reads 10.0.2.243/32 (or the bare staticIp), rule 200 destination reads 10.0.2.0/23, rule 4096 Action = Deny (with warning triangle icon).
Capture path: Network security group (nsg-snet-cae-kl4kt7) → Overview.
Fixed — Application Gateway Backend health¶

Portal evidence captured — Application Gateway → Backend health (after fix.sh)
Every backend server returns to Healthy (green check, Healthy: 1 out of 1, Received 200 status code). This is the falsification screenshot — the ONLY change between the broken and fixed captures was rule 100's Destination address (10.0.2.243/32 → 10.0.2.0/23), so the recovery is directly attributable to the NSG-shape fix and not to any AppGW, DNS, ILB, or ingress-side change.
Look for: green Healthy status, 1 out of 1 count, Received 200 status code in the details cell — matching the baseline capture exactly.
Capture path: Application Gateway (agw-appgwnsg-kl4kt7) → Backend health → aca-backend-pool.
Fixed — NSG rule 100 Destination¶

Portal evidence captured — NSG → Overview (inbound security rules table, after fix.sh)
Rule allow-appgw-inbound-broken at priority 100 now shows Destination = 10.0.2.0/23 (the CAE subnet CIDR). Source (10.0.1.0/24 = AppGW subnet), ports (443, 31443), protocol (Tcp), priority (100), and Action (Allow) are unchanged. Rules 200 and 4096 are identical to the broken-state capture. The rule name is intentionally left as allow-appgw-inbound-broken to preserve the audit trail — a real operator's fix would also rename the rule, but the lab prefers name stability so a reviewer can trivially confirm that only the Destination address changed.
Look for: rule 100 Destination cell now reads 10.0.2.0/23 (matching rule 200's destination), all other rule properties unchanged from the broken capture.
Capture path: Network security group (nsg-snet-cae-kl4kt7) → Overview.
Recapture reference (for future re-runs)¶
If the lab is re-deployed and the Portal screenshots need to be re-captured (for example after a Portal UX change that alters the blade layout), use the table below. The five PNGs above were captured against these exact blades on 2026-07-04.
| Screenshot | File name | Blade selector |
|---|---|---|
| Baseline: AppGW backend health | 01-appgw-backend-health-baseline.png | Application Gateway → Backend health → aca-backend-pool |
| Broken: AppGW backend health | 02-appgw-backend-health-broken.png | Application Gateway → Backend health → aca-backend-pool |
| Broken: NSG rule 100 Destination | 03-nsg-rule100-broken.png | Network security group → Overview (inbound rules table) |
| Fixed: AppGW backend health | 04-appgw-backend-health-fixed.png | Application Gateway → Backend health → aca-backend-pool |
| Fixed: NSG rule 100 Destination | 05-nsg-rule100-fixed.png | Network security group → Overview (inbound rules table) |
Follow the Portal Screenshot Capture (PII Replacement Rules) documented in AGENTS.md when re-capturing: use the reusable PII helper, verify each PNG with the Read/look_at tool, and confirm no MICROSOFT NON-PRODUCTION badge, no real subscription IDs, and no employee emails or aliases are visible before referencing the PNG from this file.
Clean Up¶
| Command | Why it is used |
|---|---|
cleanup.sh | Deletes the resource group and all child resources (async). Application Gateway Standard_v2 dominates the hourly cost of this lab (~$0.36/hour + capacity-unit charges); always run cleanup.sh immediately after capturing evidence. |
Related Playbook¶
See Also¶
- Application Gateway Integration with an Internal Container Apps Environment
- UDR and NSG Egress Blocked Lab
- Private Endpoint DNS Failure Lab
- Ingress Not Reachable Playbook
- Networking Best Practices
Sources¶
- Securing a Virtual Network in Azure Container Apps (workload profiles) (Microsoft Learn)
- Protect Azure Container Apps with Application Gateway and Web Application Firewall (WAF) (Microsoft Learn)
- Provide an internal environment for Azure Container Apps (Microsoft Learn)
- Networking in Azure Container Apps environment (Microsoft Learn)
- Network security groups (Microsoft Learn)
- Application Gateway backend health monitoring (Microsoft Learn)
- Application Gateway health probes overview (Microsoft Learn)
- Bicep BCP120 — value must be a compile-time constant (Microsoft Learn)