Enterprise-Grade MCP Servers: App Gateway + Internal APIM + Container Apps

Enterprise-Grade MCP Servers: App Gateway + Internal APIM + Container Apps

The Problem

You’ve built an MCP server on Azure API Management. It works. AI agents can discover and call your tools. But your security team has questions:

  • “Why does APIM have a public IP?”
  • “Where’s the WAF?”
  • “Can we put this behind Application Gateway for DDoS protection?”
  • “Does this meet our zero-trust requirements?”

Fair questions. In a basic public deployment (Scenario 1 — Public), both APIM and the Container App are internet-accessible — two public endpoints with no network isolation. In a VNet-integrated deployment (Scenario 2 — VNet), the Container App is private but APIM still has a public endpoint — better, but APIM is still directly reachable from the internet. Neither satisfies an enterprise security team.

📐 View Scenario 1 Architecture (Public)

Scenario 1 Architecture

📐 View Scenario 2 Architecture (VNet)

Scenario 2 Architecture

Scenario 3 fixes all of that: Application Gateway is the only thing touching the internet. Everything else is fully private.


What We’re Building

Architecture Overview

mcp-demo-appgw-rg

The traffic flow:

Traffic Flow

Three services, three subnets, one VNet — and only the Application Gateway has a public IP.


Why App Gateway + Internal APIM?

If you’re coming from Scenario 2 (VNet-integrated APIM in external mode), you might be wondering: why add another layer?

Scenario 2 (VNet)Scenario 3 (App Gateway)
Internet entry pointAPIM (external VNet mode)App Gateway (public IP)
APIM exposurePublic + VNet (has public IP)Internal VNet only (no public IP)
WAF protection❌ None✅ App Gateway WAF (OWASP rules)
DDoS protectionBasic✅ Standard (via App GW)
Public endpoints1 (APIM)1 (App Gateway)
API endpoint exposed?Yes — APIM URL is publicNo — APIM is fully private
Security rating⭐⭐⭐⭐⭐⭐⭐⭐

The key difference: in Scenario 2, anyone who knows your APIM URL can send requests directly to it. In Scenario 3, APIM doesn’t have a URL on the internet at all. The only way in is through Application Gateway — which means WAF rules, DDoS protection, IP allowlisting, and SSL termination all happen before the request reaches APIM.

apim-internal


The Three Security Layers

This is what makes the pattern powerful — each layer handles a different dimension of security:

Layer 1: Application Gateway (Network Security)

CapabilityWhat It Does
WAF (OWASP 3.2)Blocks SQL injection, XSS, and common web attacks
DDoS protectionAzure DDoS Standard integration
SSL terminationTLS 1.2+ with managed certificates
IP allowlistingRestrict access to known client IP ranges
Custom routingPath-based or host-based routing rules
Health probesMonitors APIM backend availability

Application Gateway WAF configuration, showing OWASP rule sets enabled

Layer 2: API Management (Application Security)

CapabilityWhat It Does
JWT validationVerifies Entra ID tokens (audience, issuer, signature)
Per-tool role checkingExtracts tool name from MCP JSON-RPC body, checks user roles
Rate limitingThrottles requests per subscription/user
Request loggingFull audit trail of API and MCP calls
MCP ↔ REST translationConverts MCP tool calls to REST API operations

This is the layer where JWT validation, per-tool role extraction from MCP JSON-RPC bodies, and role-based access control happen — all enforced in APIM policies with zero code changes to the API itself. I covered this in depth in my previous post on securing MCP servers with Entra ID OAuth 2.0.

Layer 3: Container App (Isolation)

CapabilityWhat It Does
No public endpointVNet-only ingress — not accessible from the internet
Zero auth codeThe API knows nothing about authentication
Minimal attack surfaceOnly port 3000, only responds to APIM’s subnet
Auto-scaling1-3 replicas based on load

Azure Portal — Container App Ingress settings showing "VNet" traffic source (not "Anywhere")


Step-by-Step Deployment

The full deployment is automated in deploy-appgw.sh — a 500-line script that provisions everything from scratch (Find the details at the end of this blog). Here’s what each step does and why.

Step 1: Create the VNet with Three Subnets

# VNet: 10.1.0.0/16
az network vnet create \
  --name mcp-appgw-vnet \
  --address-prefix "10.1.0.0/16"

# Subnet 1: Container Apps (needs /23 minimum)
az network vnet subnet create \
  --name aca-subnet \
  --address-prefix "10.1.0.0/23" \
  --delegations "Microsoft.App/environments"

# Subnet 2: APIM (dedicated, no delegation)
az network vnet subnet create \
  --name apim-subnet \
  --address-prefix "10.1.2.0/24"

# Subnet 3: App Gateway (dedicated)
az network vnet subnet create \
  --name appgw-subnet \
  --address-prefix "10.1.3.0/24"

⚠️ Important: Container Apps environments require a /23 subnet (512 addresses). APIM and App Gateway each need a /24. Plan your address space accordingly.

Azure Portal — VNet Subnets blade showing all three subnets with their address ranges and delegations

Step 2: Deploy the Container App

There’s a subtle but critical two-layer concept here:

# Create environment — internal-only means the environment itself
# has NO public IP. It lives entirely inside the VNet.
az containerapp env create \
  --name mcp-appgw-env \
  --infrastructure-subnet-resource-id $ACA_SUBNET_ID \
  --internal-only true

# Deploy the app with "external" ingress — this does NOT mean
# internet-accessible. Within an internal-only environment,
# "external" means "reachable from other subnets in the VNet."
# Without this, APIM (in its own subnet) can't reach the app.
az containerapp create \
  --name product-catalog-appgw \
  --environment mcp-appgw-env \
  --image $ACR_NAME.azurecr.io/product-catalog-api:v1 \
  --target-port 3000 \
  --ingress external

Why --internal-only true + --ingress external? This is the combination that trips people up:

SettingWhat It ControlsValueEffect
--internal-onlyEnvironment network exposuretrueNo public IP for the entire environment
--ingressApp accessibility within the environmentexternalOther subnets in the VNet can reach this app

If you set --ingress internal instead, only other Container Apps within the same environment could reach it — APIM (which lives in a different subnet) would get connection refused.

The resulting FQDN looks like: product-catalog-appgw.icyri<>.eastus.azurecontainerapps.io. This looks like a public URL, but it isn’t — with --internal-only true, Azure does not create a public DNS record for it. The FQDN is just an identifier shown in the portal. It only resolves from within the VNet, via the Private DNS zone you configure in Step 4.

Azure Portal — Container App overview showing the FQDN and "Running" status. Note: the FQDN looks public but has no public DNS — it only resolves within the VNet via Private DNS

Step 3: Deploy APIM in Internal VNet Mode

az apim create \
  --name mcp-appgw-apim-XXXXXX \
  --sku-name Developer \
  --virtual-network-type Internal \
  --virtual-network $APIM_SUBNET_ID

This is the biggest difference from Scenario 2. With --virtual-network-type Internal:

  • APIM gets a private IP only (e.g., 10.1.2.4)
  • There is no public endpoint
  • The *.azure-api.net hostname only resolves inside the VNet (via Private DNS)
  • APIM management plane is accessible only through the VNet or a connected network

⚠️ Deployment time: Internal VNet APIM takes 30-45 minutes to provision. This is normal. The Developer SKU is the minimum that supports VNet integration.

Azure Portal — APIM Network blade showing "Internal" virtual network type, the VNet name, subnet, and private IP address

Step 4: Private DNS Configuration

Internal APIM requires Private DNS zones so that services within the VNet can resolve *.azure-api.net to the private IP:

# APIM DNS
az network private-dns zone create --name "azure-api.net"
az network private-dns record-set a add-record \
  --zone-name "azure-api.net" \
  --record-set-name "$APIM_NAME" \
  --ipv4-address "$APIM_PRIVATE_IP"

# Container App DNS (for APIM → ACA routing)
az network private-dns zone create \
  --name "$ACA_ENVIRONMENT_DOMAIN"
az network private-dns record-set a add-record \
  --zone-name "$ACA_ENVIRONMENT_DOMAIN" \
  --record-set-name "*" \
  --ipv4-address "$ACA_STATIC_IP"

⚠️ DNS note: The wildcard record * resolves any app name under the environment domain to the static IP. Make sure the Private DNS zone is linked to the VNet — otherwise APIM can’t resolve the Container App hostname and requests will fail with connection errors. A common pitfall is creating the DNS zone but forgetting to link it to the VNet, or using the wrong record name (e.g., *.internal instead of *) — the exact record name depends on whether your Container App FQDN includes an .internal. subdomain segment.

Private DNS Zone for azure-api.net, showing the A record pointing to APIM's private IP

Step 5: Import the API and Create the MCP Server

# Import OpenAPI spec into APIM
az apim api import \
  --service-name $APIM_NAME \
  --api-id product-catalog-appgw \
  --specification-format OpenApiJson \
  --specification-path openapi.json \
  --service-url "https://${APP_FQDN}" \
  --subscription-required false

Then in the Azure Portal:

  1. Navigate to your APIM instance → MCP Servers
  2. Click + Create MCP serverExpose an API as MCP server
  3. Select the Product Catalog API and all its operations
  4. Click Create

APIM MCP Servers blade showing the created MCP server with its endpoint URL

Step 6: Deploy Application Gateway

This is the public-facing entry point:

# Public IP
az network public-ip create \
  --name mcp-appgw-pip \
  --sku Standard \
  --allocation-method Static

# Application Gateway
az network application-gateway create \
  --name mcp-appgw \
  --sku Standard_v2 \
  --capacity 1 \
  --vnet-name mcp-appgw-vnet \
  --subnet appgw-subnet \
  --public-ip-address mcp-appgw-pip \
  --http-settings-port 443 \
  --http-settings-protocol Https \
  --servers "$APIM_PRIVATE_IP" \
  --frontend-port 80

Step 7: Configure App Gateway Backend Settings

The App Gateway needs to send the correct hostname to APIM and monitor its health:

# Set host header to APIM's internal hostname
az network application-gateway http-settings update \
  --gateway-name mcp-appgw \
  --name appGatewayBackendHttpSettings \
  --host-name "${APIM_NAME}.azure-api.net" \
  --protocol Https --port 443

# Health probe using APIM's built-in status endpoint
az network application-gateway probe create \
  --gateway-name mcp-appgw \
  --name apim-probe \
  --protocol Https \
  --host "${APIM_NAME}.azure-api.net" \
  --path "/status-0123456789abcdef" \
  --interval 30 --timeout 30 --threshold 3

Why the host header matters: App Gateway connects to APIM by private IP, but APIM expects requests addressed to its *.azure-api.net hostname. Without the host header override, APIM returns 404.

Application Gateway Backend settings showing the APIM hostname, HTTPS protocol, and the health probe configuration


The Data Flow — End to End

Here’s what happens when a Copilot Studio agent calls listAllProducts:

Data Flow

Four hops, three security checks, zero public API endpoints. The Container App has no idea any of this is happening — it just serves JSON.


Connecting MCP Clients

From the client’s perspective, the MCP endpoint is simply the App Gateway’s public IP:

MCP Endpoint: http://{APP_GATEWAY_PUBLIC_IP}/mcp

MCP Inspector:

npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP
# URL: http://{APP_GATEWAY_PUBLIC_IP}/mcp

Copilot Studio:

  • Server URL: http://{APP_GATEWAY_PUBLIC_IP}/mcp
  • Authentication: OAuth 2.0 (Entra ID) — configure with client ID, client secret, and your tenant’s authorization/token URLs. APIM validates the JWT and enforces per-tool role-based access.

GitHub Copilot (VS Code):

// settings.json
"mcp": {
  "servers": {
    "product-catalog": {
      "type": "http",
      "url": "http://{APP_GATEWAY_PUBLIC_IP}/mcp"
    }
  }
}

MCP Inspector connected to the App Gateway endpoint, showing the tools list returned by the MCP server


All Three Scenarios Compared

Scenario 1 (Public)Scenario 2 (VNet)Scenario 3 (App Gateway)
Internet entryAPIM (public)APIM (external VNet)App Gateway (public IP)
APIM exposurePublic endpoint, no VNetPublic + VNetInternal VNet, no public endpoint
Container AppExternal ingress (public)External ingress (VNet-only)External ingress (VNet-only)
WAF❌ None❌ None✅ App Gateway WAF
DDoSBasicBasic✅ Standard
Public endpoints2 (APIM + ACA)1 (APIM)1 (App Gateway only)
Security level⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
ComplexityLowMediumHigh
Deployment time~15 min~40 min~50 min
MCP endpointAPIM public URL /mcpAPIM public URL /mcpApp GW public IP /mcp

Hard-Won Lessons

1. APIM Internal Mode Takes Forever

Internal VNet APIM provisioning takes 30-45 minutes. Don’t cancel and retry — the deployment is working, it’s just slow. Use this time to set up the other resources.

2. The Host Header Is Non-Negotiable

App Gateway connects to APIM by private IP, but APIM routes based on the Host header. If you forget to set --host-name "${APIM_NAME}.azure-api.net" on the backend HTTP settings, every request returns 404.

3. Private DNS Is Critical — And Easy to Get Wrong

Internal APIM requires a Private DNS zone for azure-api.net linked to your VNet. Without it, App Gateway can’t resolve APIM’s hostname and the health probe fails permanently.

Container Apps also need a Private DNS zone for their environment domain. A wildcard * A record pointing to the environment’s static IP ensures APIM can resolve any app in the environment. If the DNS zone isn’t linked to the VNet or the record name is wrong, APIM will fail to connect to the Container App with cryptic timeout errors.

4. --internal-only vs --ingress Are Different Things

The Container Apps environment’s --internal-only true removes the public IP from the environment. The app’s --ingress external makes it reachable from other VNet subnets (like APIM’s). If you accidentally set --ingress internal, APIM won’t be able to reach the Container App even though they share a VNet — because internal ingress restricts access to only other apps in the same Container Apps environment.

5. Health Probe Path Matters

APIM’s health endpoint is /status-0123456789abcdef — a fixed path that returns 200 when APIM is healthy. Don’t use / or /mcp as your health probe path — they require authentication and will return 401, making App Gateway think the backend is down.

6. App Gateway Needs the APIM Certificate

When App Gateway connects to APIM over HTTPS (which it should), it needs to trust APIM’s TLS certificate. For Developer SKU APIM with the default *.azure-api.net certificate, this usually works out of the box. For custom domains, you’ll need to upload the root CA to App Gateway’s trusted root certificates.


When to Use This Pattern

Use Scenario 3 when:

  • Your organization requires WAF protection for all public endpoints
  • Compliance mandates zero public API endpoints (only WAF-fronted entry points)
  • You need DDoS Standard protection
  • You want IP allowlisting at the network edge
  • You’re operating in a regulated industry (finance, healthcare, government)

Stick with Scenario 2 when:

  • WAF isn’t required
  • You want simpler operations (fewer moving parts)
  • Cost is a concern (App Gateway Standard_v2 starts at ~$250/mo)

Stick with Scenario 1 when:

  • You’re prototyping or in development
  • Security requirements are minimal
  • Speed of deployment matters most

Summary

LayerServiceWhat It Does
EdgeApplication GatewayWAF, DDoS, SSL termination, IP filtering
APIAPI Management (Internal)JWT validation, per-tool role checking, MCP↔REST translation
ComputeContainer AppsRuns the API — knows nothing about networking or auth
IdentityEntra IDOAuth 2.0 tokens, app roles, group-based access
NetworkVNet + Private DNSIsolates everything, resolves private hostnames

The beauty of this pattern is separation of concerns. The API code doesn’t change between scenarios — it’s the same Express app in all three. Security, networking, and access control are all handled by infrastructure.

The deployment script (500 lines of Azure CLI) provisions everything from scratch — VNet, subnets, ACR, Container App, APIM, DNS zones, App Gateway, and routing. For the full auth setup (Entra ID app registrations, app roles, APIM policies), see my companion post on securing MCP servers with OAuth 2.0.

Full Deployment Script

The complete 500-line deployment script that provisions everything from scratch:

📜 Click to expand deploy-appgw.sh (512 lines)
#!/usr/bin/env bash
#
# Scenario 3: App Gateway → APIM (Internal VNet) → Container App (VNet)
#
# Architecture:
#   [Internet] → [App Gateway (public, WAF)] → [APIM (internal VNet)]
#                                                       ↓
#                                              [Container App (VNet-only)]
#
# The MCP endpoint is exposed through App Gateway.
#
# Usage: ./scripts/deploy-appgw.sh
#
set -euo pipefail

#-----------------------------------------------------------------------
# Configuration
#-----------------------------------------------------------------------
RESOURCE_GROUP="mcp-demo-appgw-rg"
LOCATION="eastus"
ACR_NAME="mcpappgwacr$(openssl rand -hex 3)"
VNET_NAME="mcp-appgw-vnet"
ACA_SUBNET_NAME="aca-subnet"
APIM_SUBNET_NAME="apim-subnet"
APPGW_SUBNET_NAME="appgw-subnet"
ACA_ENV_NAME="mcp-appgw-env"
ACA_APP_NAME="product-catalog-appgw"
APIM_NAME="mcp-appgw-apim-$(openssl rand -hex 3)"
APPGW_NAME="mcp-appgw"
APPGW_PIP_NAME="mcp-appgw-pip"
APIM_ORG="MCP Demo AppGW"
APIM_EMAIL="admin@mcpdemo.com"
API_ID="product-catalog-appgw"
IMAGE_TAG="product-catalog-api:v1"

echo "======================================================="
echo " Scenario 3: App Gateway → APIM (Internal) → ACA"
echo "======================================================="

#-----------------------------------------------------------------------
# Step 1: Create Resource Group
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 1: Creating resource group '$RESOURCE_GROUP'..."
az group create --name "$RESOURCE_GROUP" --location "$LOCATION" --output none
echo "  ✅ Resource group created."

#-----------------------------------------------------------------------
# Step 2: Create VNet with 3 subnets
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 2: Creating VNet with 3 subnets..."
az network vnet create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$VNET_NAME" \
  --address-prefix "10.1.0.0/16" \
  --location "$LOCATION" \
  --output none

# ACA subnet
az network vnet subnet create \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name "$VNET_NAME" \
  --name "$ACA_SUBNET_NAME" \
  --address-prefix "10.1.0.0/23" \
  --delegations "Microsoft.App/environments" \
  --output none

# APIM subnet
az network vnet subnet create \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name "$VNET_NAME" \
  --name "$APIM_SUBNET_NAME" \
  --address-prefix "10.1.2.0/24" \
  --output none

# App Gateway subnet
az network vnet subnet create \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name "$VNET_NAME" \
  --name "$APPGW_SUBNET_NAME" \
  --address-prefix "10.1.3.0/24" \
  --output none

ACA_SUBNET_ID=$(az network vnet subnet show \
  --resource-group "$RESOURCE_GROUP" --vnet-name "$VNET_NAME" \
  --name "$ACA_SUBNET_NAME" --query id -o tsv)

APIM_SUBNET_ID=$(az network vnet subnet show \
  --resource-group "$RESOURCE_GROUP" --vnet-name "$VNET_NAME" \
  --name "$APIM_SUBNET_NAME" --query id -o tsv)

echo "  ✅ VNet and subnets created."
echo "      ACA subnet:     10.1.0.0/23"
echo "      APIM subnet:    10.1.2.0/24"
echo "      App GW subnet:  10.1.3.0/24"

#-----------------------------------------------------------------------
# Step 3: Create ACR + Build image
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 3: Creating ACR and building image..."
az acr create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$ACR_NAME" \
  --sku Basic \
  --admin-enabled true \
  --output none

az acr build \
  --registry "$ACR_NAME" \
  --image "$IMAGE_TAG" \
  --file Dockerfile . \
  --no-logs
echo "  ✅ Image built and pushed."

#-----------------------------------------------------------------------
# Step 4: Create Container Apps environment (internal)
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 4: Creating internal Container Apps environment..."
az containerapp env create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$ACA_ENV_NAME" \
  --location "$LOCATION" \
  --infrastructure-subnet-resource-id "$ACA_SUBNET_ID" \
  --internal-only true \
  --output none

ACA_DEFAULT_DOMAIN=$(az containerapp env show \
  --resource-group "$RESOURCE_GROUP" --name "$ACA_ENV_NAME" \
  --query "properties.defaultDomain" -o tsv)

ACA_STATIC_IP=$(az containerapp env show \
  --resource-group "$RESOURCE_GROUP" --name "$ACA_ENV_NAME" \
  --query "properties.staticIp" -o tsv)

echo "  ✅ ACA environment created."
echo "      Domain: $ACA_DEFAULT_DOMAIN"
echo "      IP:     $ACA_STATIC_IP"

#-----------------------------------------------------------------------
# Step 5: Deploy Container App (external ingress = VNet-accessible)
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 5: Deploying Container App..."
ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io"
ACR_PASSWORD=$(az acr credential show --name "$ACR_NAME" --query "passwords[0].value" -o tsv)

az containerapp create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$ACA_APP_NAME" \
  --environment "$ACA_ENV_NAME" \
  --image "${ACR_LOGIN_SERVER}/${IMAGE_TAG}" \
  --registry-server "$ACR_LOGIN_SERVER" \
  --registry-username "$ACR_NAME" \
  --registry-password "$ACR_PASSWORD" \
  --target-port 3000 \
  --ingress external \
  --min-replicas 1 \
  --max-replicas 3 \
  --output none

APP_FQDN=$(az containerapp show \
  --resource-group "$RESOURCE_GROUP" --name "$ACA_APP_NAME" \
  --query "properties.configuration.ingress.fqdn" -o tsv)

echo "  ✅ Container App deployed: https://${APP_FQDN}"
echo "     (VNet-only, not internet-accessible)"

#-----------------------------------------------------------------------
# Step 6: Private DNS for Container Apps
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 6: Setting up Private DNS..."
az network private-dns zone create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$ACA_DEFAULT_DOMAIN" \
  --output none

az network private-dns link vnet create \
  --resource-group "$RESOURCE_GROUP" \
  --zone-name "$ACA_DEFAULT_DOMAIN" \
  --name "aca-dns-link" \
  --virtual-network "$VNET_NAME" \
  --registration-enabled false \
  --output none

az network private-dns record-set a add-record \
  --resource-group "$RESOURCE_GROUP" \
  --zone-name "$ACA_DEFAULT_DOMAIN" \
  --record-set-name "*" \
  --ipv4-address "$ACA_STATIC_IP" \
  --output none

echo "  ✅ Private DNS configured."

#-----------------------------------------------------------------------
# Step 7: NSG + Service Endpoints for APIM subnet
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 7: Configuring NSG and service endpoints..."

az network vnet subnet update \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name "$VNET_NAME" \
  --name "$APIM_SUBNET_NAME" \
  --service-endpoints Microsoft.EventHub Microsoft.KeyVault Microsoft.ServiceBus Microsoft.Sql Microsoft.Storage \
  --output none

az network nsg create \
  --resource-group "$RESOURCE_GROUP" \
  --name "apim-nsg" \
  --location "$LOCATION" \
  --output none

az network nsg rule create \
  --resource-group "$RESOURCE_GROUP" \
  --nsg-name "apim-nsg" \
  --name "AllowAPIMManagement" \
  --priority 100 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes ApiManagement \
  --destination-port-ranges 3443 \
  --output none

az network nsg rule create \
  --resource-group "$RESOURCE_GROUP" \
  --nsg-name "apim-nsg" \
  --name "AllowAppGateway" \
  --priority 110 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes "10.1.3.0/24" \
  --destination-port-ranges 443 \
  --output none

az network vnet subnet update \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name "$VNET_NAME" \
  --name "$APIM_SUBNET_NAME" \
  --network-security-group "apim-nsg" \
  --output none

echo "  ✅ NSG and service endpoints configured."

#-----------------------------------------------------------------------
# Step 7b: NSG for App Gateway subnet (allow HTTP:80 inbound)
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 7b: Configuring App Gateway subnet NSG..."
az network nsg create \
  --resource-group "$RESOURCE_GROUP" \
  --name "appgw-nsg" \
  --location "$LOCATION" \
  --output none

az network nsg rule create \
  --resource-group "$RESOURCE_GROUP" \
  --nsg-name "appgw-nsg" \
  --name "AllowHTTP" \
  --priority 100 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes Internet \
  --destination-port-ranges 80 \
  --output none

az network nsg rule create \
  --resource-group "$RESOURCE_GROUP" \
  --nsg-name "appgw-nsg" \
  --name "AllowGatewayManager" \
  --priority 200 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes GatewayManager \
  --destination-port-ranges 65200-65535 \
  --output none

az network vnet subnet update \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name "$VNET_NAME" \
  --name "$APPGW_SUBNET_NAME" \
  --network-security-group "appgw-nsg" \
  --output none

echo "  ✅ App Gateway NSG configured (HTTP:80 + GatewayManager)."

#-----------------------------------------------------------------------
# Step 8: Create APIM (Internal VNet mode)
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 8: Creating APIM with Internal VNet mode..."
echo "  ⏳ This takes 30-45 minutes..."

az apim create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$APIM_NAME" \
  --publisher-name "$APIM_ORG" \
  --publisher-email "$APIM_EMAIL" \
  --sku-name Developer \
  --location "$LOCATION" \
  --output none

echo "  ✅ APIM instance created. Applying VNet integration..."

az apim update \
  --resource-group "$RESOURCE_GROUP" \
  --name "$APIM_NAME" \
  --virtual-network Internal \
  --set "virtualNetworkConfiguration.subnetResourceId=$APIM_SUBNET_ID" \
  --output none

APIM_PRIVATE_IP=$(az apim show \
  --resource-group "$RESOURCE_GROUP" --name "$APIM_NAME" \
  --query "privateIpAddresses[0]" -o tsv 2>/dev/null || echo "pending")

echo "  ✅ APIM deployed (Internal VNet mode)."
echo "      Private IP: $APIM_PRIVATE_IP"
echo "      Gateway:    ${APIM_NAME}.azure-api.net (VNet-only)"

#-----------------------------------------------------------------------
# Step 9: Private DNS for APIM
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 9: Setting up Private DNS for APIM..."

# Internal APIM requires private DNS to resolve *.azure-api.net within the VNet
az network private-dns zone create \
  --resource-group "$RESOURCE_GROUP" \
  --name "azure-api.net" \
  --output none

az network private-dns link vnet create \
  --resource-group "$RESOURCE_GROUP" \
  --zone-name "azure-api.net" \
  --name "apim-dns-link" \
  --virtual-network "$VNET_NAME" \
  --registration-enabled false \
  --output none

# Re-fetch private IP (might not have been available immediately)
APIM_PRIVATE_IP=$(az apim show \
  --resource-group "$RESOURCE_GROUP" --name "$APIM_NAME" \
  --query "privateIpAddresses[0]" -o tsv)

az network private-dns record-set a add-record \
  --resource-group "$RESOURCE_GROUP" \
  --zone-name "azure-api.net" \
  --record-set-name "$APIM_NAME" \
  --ipv4-address "$APIM_PRIVATE_IP" \
  --output none

echo "  ✅ APIM Private DNS configured."
echo "      ${APIM_NAME}.azure-api.net → $APIM_PRIVATE_IP"

#-----------------------------------------------------------------------
# Step 10: Import API into APIM
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 10: Importing API into APIM..."

az apim api import \
  --resource-group "$RESOURCE_GROUP" \
  --service-name "$APIM_NAME" \
  --api-id "$API_ID" \
  --path "products" \
  --specification-format OpenApiJson \
  --specification-path "openapi.json" \
  --service-url "https://${APP_FQDN}" \
  --display-name "Product Catalog API (AppGW)" \
  --protocols https \
  --subscription-required false \
  --output none

echo "  ✅ API imported."

#-----------------------------------------------------------------------
# Step 11: Create App Gateway (public-facing)
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 11: Creating Application Gateway..."

# Public IP for App Gateway
az network public-ip create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$APPGW_PIP_NAME" \
  --sku Standard \
  --allocation-method Static \
  --location "$LOCATION" \
  --output none

APPGW_PIP=$(az network public-ip show \
  --resource-group "$RESOURCE_GROUP" --name "$APPGW_PIP_NAME" \
  --query ipAddress -o tsv)

echo "  App Gateway Public IP: $APPGW_PIP"

# Create App Gateway with APIM as backend
az network application-gateway create \
  --resource-group "$RESOURCE_GROUP" \
  --name "$APPGW_NAME" \
  --location "$LOCATION" \
  --sku Standard_v2 \
  --capacity 1 \
  --vnet-name "$VNET_NAME" \
  --subnet "$APPGW_SUBNET_NAME" \
  --public-ip-address "$APPGW_PIP_NAME" \
  --http-settings-port 443 \
  --http-settings-protocol Https \
  --frontend-port 80 \
  --servers "$APIM_PRIVATE_IP" \
  --priority 100 \
  --output none

echo "  ✅ App Gateway created."

#-----------------------------------------------------------------------
# Step 12: Configure App Gateway for APIM backend
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 12: Configuring App Gateway routing to APIM..."

# Update backend HTTP settings to use APIM hostname
az network application-gateway http-settings update \
  --resource-group "$RESOURCE_GROUP" \
  --gateway-name "$APPGW_NAME" \
  --name "appGatewayBackendHttpSettings" \
  --host-name "${APIM_NAME}.azure-api.net" \
  --protocol Https \
  --port 443 \
  --output none

# Create a health probe for APIM
az network application-gateway probe create \
  --resource-group "$RESOURCE_GROUP" \
  --gateway-name "$APPGW_NAME" \
  --name "apim-probe" \
  --protocol Https \
  --host "${APIM_NAME}.azure-api.net" \
  --path "/status-0123456789abcdef" \
  --interval 30 \
  --timeout 30 \
  --threshold 3 \
  --output none

# Associate probe with HTTP settings
az network application-gateway http-settings update \
  --resource-group "$RESOURCE_GROUP" \
  --gateway-name "$APPGW_NAME" \
  --name "appGatewayBackendHttpSettings" \
  --probe "apim-probe" \
  --output none

echo "  ✅ App Gateway configured to route to APIM."

#-----------------------------------------------------------------------
# Step 13: MCP Server Setup Instructions
#-----------------------------------------------------------------------
echo ""
echo "▶ Step 13: Exposing API as MCP Server..."
echo ""
echo "  📋 MANUAL STEP (Portal):"
echo "  ─────────────────────────────────────────────────"
echo "  1. Azure Portal → APIM: ${APIM_NAME}"
echo "  2. MCP Servers → + Create MCP server"
echo "  3. Expose an API as MCP server"
echo "  4. Select 'Product Catalog API (AppGW)'"
echo "  5. Select all operations → Create"
echo "  ─────────────────────────────────────────────────"
echo ""

#-----------------------------------------------------------------------
# Summary
#-----------------------------------------------------------------------
echo "======================================================="
echo " DEPLOYMENT COMPLETE — Scenario 3 (App Gateway)"
echo "======================================================="
echo ""
echo " Resource Group:    $RESOURCE_GROUP"
echo " VNet:              $VNET_NAME (10.1.0.0/16)"
echo ""
echo " Container App:     https://${APP_FQDN}"
echo "                    (VNet-only, not internet-accessible)"
echo ""
echo " APIM:              ${APIM_NAME}.azure-api.net"
echo "                    (Internal VNet, private IP: $APIM_PRIVATE_IP)"
echo ""
echo " App Gateway:       http://${APPGW_PIP}  (public)"
echo "                    Routes to APIM internal endpoint"
echo ""
echo " Architecture:"
echo "   [Internet] → [App GW :${APPGW_PIP}] → [APIM (internal)] → [Container App]"
echo ""
echo " MCP Endpoint (after portal step):"
echo "   http://${APPGW_PIP}/mcp"
echo ""
echo " Test with MCP Inspector:"
echo "   npx @modelcontextprotocol/inspector"
echo "   → Transport: Streamable HTTP"
echo "   → URL: http://${APPGW_PIP}/mcp"
echo ""

# Save outputs
cat > scripts/.env-appgw <<EOF
RESOURCE_GROUP=$RESOURCE_GROUP
VNET_NAME=$VNET_NAME
ACR_NAME=$ACR_NAME
ACA_ENV_NAME=$ACA_ENV_NAME
ACA_APP_NAME=$ACA_APP_NAME
APIM_NAME=$APIM_NAME
APIM_PRIVATE_IP=$APIM_PRIVATE_IP
APPGW_NAME=$APPGW_NAME
APPGW_PIP=$APPGW_PIP
APP_FQDN=$APP_FQDN
MCP_ENDPOINT=http://${APPGW_PIP}/mcp
EOF

echo " Environment saved to scripts/.env-appgw"

Happy hacking! In the next post, I’ll show you how to secure this architecture with Entra ID and APIM policies for OAuth 2.0 authentication and per-tool authorization.