A complete step-by-step guide to deploy Azure Container Apps inside a Virtual Network with Application Gateway for secure, production-grade internet exposure.
Start by creating a resource group and designing your VNet with dedicated subnets. Proper subnet sizing is critical โ Container Apps requires a /23 minimum for the environment subnet.
10.0.0.0/16 for the VNet, giving you room to grow.aca-subnet (10.0.0.0/23) for Container Apps, agw-subnet (10.0.2.0/24) for App Gateway.# Variables RG="rg-myapp-prod" LOCATION="eastus2" VNET="vnet-myapp" # Create resource group az group create \ --name $RG \ --location $LOCATION # Create VNet + ACA subnet (min /23 required) az network vnet create \ --resource-group $RG \ --name $VNET \ --address-prefix 10.0.0.0/16 \ --subnet-name "aca-subnet" \ --subnet-prefix 10.0.0.0/23 # Create App Gateway subnet az network vnet subnet create \ --resource-group $RG \ --vnet-name $VNET \ --name "agw-subnet" \ --address-prefix 10.0.2.0/24
Use ACR with a Private Endpoint so image pulls stay on the private network. Use the Premium SKU to enable private endpoints and geo-replication.
ACR_NAME="acrMyApp$(shuf -i 1000-9999 -n 1)" # Create ACR (Premium for private endpoint) az acr create \ --resource-group $RG \ --name $ACR_NAME \ --sku Premium \ --public-network-enabled false # Create private endpoint for ACR ACR_ID=$(az acr show --name $ACR_NAME -g $RG --query id -o tsv) az network private-endpoint create \ --resource-group $RG \ --name "pe-acr" \ --vnet-name $VNET \ --subnet "aca-subnet" \ --private-connection-resource-id $ACR_ID \ --group-id registry \ --connection-name "acr-connection" # Link private DNS zone az network private-dns zone create -g $RG \ -n "privatelink.azurecr.io" az network private-dns link vnet create -g $RG \ --zone-name "privatelink.azurecr.io" \ --name "acr-dns-link" \ --virtual-network $VNET \ --registration-enabled false
az acr build to build directly in Azure without needing Docker locally.Use a User-Assigned Managed Identity for your Container App to pull from ACR. This avoids storing registry credentials and follows least-privilege principles.
# Create managed identity az identity create \ --resource-group $RG \ --name "id-myapp" IDENTITY_ID=$(az identity show -g $RG -n "id-myapp" --query id -o tsv) IDENTITY_PRINCIPAL=$(az identity show -g $RG -n "id-myapp" --query principalId -o tsv) # Assign AcrPull role to the identity az role assignment create \ --assignee $IDENTITY_PRINCIPAL \ --role "AcrPull" \ --scope $ACR_ID
Create the Container App Environment with VNet injection and set it to internal mode. This means no public IP is assigned to the environment โ traffic must come through App Gateway.
ACA_ENV="cae-myapp-prod" ACA_SUBNET_ID=$(az network vnet subnet show \ -g $RG --vnet-name $VNET --name "aca-subnet" --query id -o tsv) # Create Log Analytics workspace for monitoring az monitor log-analytics workspace create \ -g $RG -n "law-myapp" LAW_ID=$(az monitor log-analytics workspace show \ -g $RG -n "law-myapp" --query customerId -o tsv) LAW_KEY=$(az monitor log-analytics workspace get-shared-keys \ -g $RG -n "law-myapp" --query primarySharedKey -o tsv) # Create Container App Environment โ INTERNAL mode az containerapp env create \ --resource-group $RG \ --name $ACA_ENV \ --location $LOCATION \ --infrastructure-subnet-resource-id $ACA_SUBNET_ID \ --internal-only true \ --logs-workspace-id $LAW_ID \ --logs-workspace-key $LAW_KEY
Deploy your container app with ingress set to internal. This assigns a private IP within the VNet that App Gateway will use as its backend.
APP_NAME="myapp" IMAGE="${ACR_NAME}.azurecr.io/myapp:latest" az containerapp create \ --resource-group $RG \ --name $APP_NAME \ --environment $ACA_ENV \ --image $IMAGE \ --target-port 8080 \ --ingress internal \ --registry-server "${ACR_NAME}.azurecr.io" \ --registry-identity $IDENTITY_ID \ --user-assigned $IDENTITY_ID \ --cpu 0.5 \ --memory "1.0Gi" \ --min-replicas 1 \ --max-replicas 10 # Get the internal FQDN โ use this as AGW backend ACA_FQDN=$(az containerapp show \ -g $RG -n $APP_NAME \ --query "properties.configuration.ingress.fqdn" -o tsv) echo "Internal FQDN: $ACA_FQDN"
myapp.internal.<env-id>.eastus2.azurecontainerapps.io.Application Gateway v2 with WAF_v2 SKU is the recommended way to expose internal Container Apps. It terminates TLS, forwards requests privately, and protects against OWASP Top 10 threats.
# 1. Create static public IP az network public-ip create \ -g $RG -n "pip-agw-myapp" \ --sku Standard \ --allocation-method Static \ --dns-name "myapp-prod" # 2. Create Application Gateway (WAF_v2) az network application-gateway create \ --resource-group $RG \ --name "agw-myapp" \ --location $LOCATION \ --sku WAF_v2 \ --capacity 2 \ --vnet-name $VNET \ --subnet "agw-subnet" \ --public-ip-address "pip-agw-myapp" \ --frontend-port 443 \ --http-settings-port 443 \ --http-settings-protocol Https \ --servers $ACA_FQDN \ --priority 100 # 3. Enable WAF in Prevention mode az network application-gateway waf-config set \ -g $RG \ --gateway-name "agw-myapp" \ --enabled true \ --firewall-mode Prevention \ --rule-set-type OWASP \ --rule-set-version 3.2
App Gateway needs to resolve the Container App's internal FQDN. Since it's internal-only, you must create a Private DNS Zone linked to your VNet.
# Get the internal IP of the Container App environment ACA_ENV_IP=$(az containerapp env show \ -g $RG -n $ACA_ENV \ --query "properties.staticIp" -o tsv) # Extract DNS suffix from FQDN # e.g. internal.abc123xyz.eastus2.azurecontainerapps.io DNS_SUFFIX=$(echo $ACA_FQDN | sed 's/^[^.]*\.//') # Create private DNS zone az network private-dns zone create \ -g $RG -n $DNS_SUFFIX # Link to VNet az network private-dns link vnet create \ -g $RG \ --zone-name $DNS_SUFFIX \ --name "aca-dns-link" \ --virtual-network $VNET \ --registration-enabled false # Create wildcard A record โ ACA environment IP az network private-dns record-set a add-record \ -g $RG \ --zone-name $DNS_SUFFIX \ --record-set-name "*" \ --ipv4-address $ACA_ENV_IP
* A record covers all container apps in the environment. New apps you deploy will automatically resolve correctly without DNS updates.For production, configure end-to-end TLS. App Gateway terminates the public TLS, then re-encrypts to the Container App backend using the internal certificate.
Harden the deployment with NSGs, configure autoscaling rules, and enable diagnostic logs for observability.
NSG Rules for ACA Subnet:| Direction | Port | Source | Purpose |
|---|---|---|---|
| Inbound | 443 | AGW Subnet | Allow App Gateway โ Container App |
| Inbound | * | AzureLoadBalancer | Required health probes |
| Inbound | * | VirtualNetwork | Internal VNet traffic |
| Outbound | 443 | * | ACR, Key Vault, Azure APIs |
| Deny All | * | Internet | Block direct internet inbound |
# Enable Container App HTTP scaling rule az containerapp update \ -g $RG -n $APP_NAME \ --scale-rule-name "http-rule" \ --scale-rule-type http \ --scale-rule-http-concurrency 50 # Enable diagnostic logs on Container App ACA_ID=$(az containerapp show -g $RG -n $APP_NAME --query id -o tsv) LAW_FULL_ID=$(az monitor log-analytics workspace show \ -g $RG -n "law-myapp" --query id -o tsv) az monitor diagnostic-settings create \ --resource $ACA_ID \ --name "diag-aca" \ --workspace $LAW_FULL_ID \ --logs '[{"category":"ContainerAppConsoleLogs","enabled":true}]' \ --metrics '[{"category":"AllMetrics","enabled":true}]' # Enable AGW diagnostic logs AGW_ID=$(az network application-gateway show \ -g $RG -n "agw-myapp" --query id -o tsv) az monitor diagnostic-settings create \ --resource $AGW_ID \ --name "diag-agw" \ --workspace $LAW_FULL_ID \ --logs '[{"category":"ApplicationGatewayAccessLog","enabled":true},{"category":"ApplicationGatewayFirewallLog","enabled":true}]'