Azure Container Apps in a VNet, Exposed to the Internet
A step-by-step production guide: an internal-only Container Apps environment published through Application Gateway with WAF.
Voices come from this device. Listening happens in your browser; nothing is sent anywhere.
In short
Run the Container Apps environment in internal-only mode inside a VNet, pull images from ACR over a private endpoint with a managed identity, and publish the app through Application Gateway WAF v2. A private DNS zone named after the environment's default domain lets the gateway resolve the app.
This guide deploys Azure Container Apps inside a Virtual Network with no public endpoint of its own, and publishes it to the internet through Application Gateway with a Web Application Firewall. Work through the steps in order and tick each one off as you go.
Step-by-Step Guide
0 / 9 completeStart by creating a resource group and designing your VNet with dedicated subnets. Proper subnet sizing is critical: a /23 environment subnet works for every Container Apps environment type (workload-profiles environments accept as small as /27).
- 1Recommended address space: Use
10.0.0.0/16for the VNet, giving you room to grow. - 2Subnet plan:
aca-subnet (10.0.0.0/23)for Container Apps,agw-subnet (10.0.2.0/24)for App Gateway,pe-subnet (10.0.3.0/24)for private endpoints. - 3The ACA subnet is dedicated to the environment: nothing else, not even private endpoints, may be placed in it. Workload-profiles environments (the CLI default) also need it delegated to Microsoft.App/environments.
# 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 # Delegate the ACA subnet (workload-profiles environments) az network vnet subnet update \ --resource-group $RG \ --vnet-name $VNET \ --name "aca-subnet" \ --delegations Microsoft.App/environments # 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 # Create a subnet for private endpoints (kept out of the ACA subnet) az network vnet subnet create \ --resource-group $RG \ --vnet-name $VNET \ --name "pe-subnet" \ --address-prefix 10.0.3.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 "pe-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 # Register the endpoint's A record in the zone az network private-endpoint dns-zone-group create -g $RG \ --endpoint-name "pe-acr" \ --name "acr-zone-group" \ --private-dns-zone "privatelink.azurecr.io" \ --zone-name "acr"
az acr build builds in Azure without Docker locally, but once public network access is disabled ACR Tasks need a dedicated agent pool in the VNet, so run the first build before locking the registry down.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 external ingress. Because the environment is internal-only, “external” means reachable from the VNet and nothing more: there is no public IP. Ingress set to internal would accept traffic only from other apps in the same environment, and App Gateway (in its own subnet) would be refused.
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 external \ --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 app FQDN (VNet-only) — use this as the AGW backend ACA_FQDN=$(az containerapp show \ -g $RG -n $APP_NAME \ --query "properties.configuration.ingress.fqdn" -o tsv) echo "App FQDN: $ACA_FQDN"
myapp.<env-id>.eastus2.azurecontainerapps.io and resolves inside the VNet once step 7 is done.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.
- ACreate a Static Public IP (Standard SKU required for AGW v2).
- BCreate AGW with WAF_v2 SKU, point backend to the Container App internal FQDN.
- CUpload your TLS certificate, or use App Gateway's managed certificate with a custom domain.
# 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 FQDN. Because the environment is internal-only, create a Private DNS Zone named after the environment's default domain and link it 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) # The environment's default domain, e.g. abc123xyz.eastus2.azurecontainerapps.io DNS_SUFFIX=$(az containerapp env show \ -g $RG -n $ACA_ENV \ --query "properties.defaultDomain" -o tsv) # 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}]'
🚀 Pre-production checklist
Was this useful?
Anonymous. A clap or a critique is the only feedback this blog records; pick again to take it back.
Frequently asked questions
How do I expose an internal Azure Container Apps environment to the internet?
Put Application Gateway (ideally WAF v2) in its own subnet of the same VNet, point its backend pool at the container app's FQDN, and add a private DNS zone named after the environment's default domain with a wildcard A record to the environment's static IP.
Should the container app use internal or external ingress behind Application Gateway?
External. In an internal-only environment, external ingress is reachable only from the VNet. Internal ingress limits traffic to apps in the same environment, so Application Gateway would be refused.
What subnet size does an Azure Container Apps environment need?
A /23 works for every environment type; workload-profiles environments accept as small as /27 and need the subnet delegated to Microsoft.App/environments. The subnet must be dedicated to the environment.
How can Container Apps pull from ACR without stored credentials?
Give the app a user-assigned managed identity with the AcrPull role on the registry and reference that identity with --registry-identity when creating the app.