Private Container Registry (ACR with Private Endpoint)¶
Pull container images from Azure Container Registry over a private network path — no public registry exposure, no admin credentials.
Architecture¶
flowchart LR
C[Client] --> I[Container Apps Ingress]
I --> APP[Container App]
ACR[Azure Container Registry] --> APP
APP -.-> MI[Managed Identity]
MI -.-> ENTRA[Microsoft Entra ID]
MI -.-> ACR Solid arrows show runtime data flow. Dashed arrows show identity and authentication.
Private Endpoint requires ACR Premium SKU
Private Endpoints for ACR are only available on the Premium tier. Basic and Standard ACR cannot use Private Endpoints.
Overview¶
By default, Container Apps pulls images from a public ACR endpoint. In a private deployment, you:
- Deploy ACR with public access disabled and a Private Endpoint in your VNet
- Configure a user-assigned managed identity on your Container App as the pull identity
- Grant that identity
AcrPullon the registry - Configure the Container App to reference that identity for the registry
graph TD
subgraph VNet["Virtual Network (10.0.0.0/16)"]
subgraph CASubnet["Container Apps Subnet (10.0.0.0/23)"]
CAE[Container Apps Environment]
CA[Container App<br/>+ User-Assigned MI]
end
subgraph PESubnet["Private Endpoints Subnet (10.0.2.0/24)"]
PE_ACR[Private Endpoint<br/>ACR login server<br/>10.0.2.10]
PE_DATA[Private Endpoint<br/>ACR data endpoint<br/>10.0.2.11]
end
end
subgraph DNS["Private DNS Zone<br/>privatelink.azurecr.io"]
DNS_LOGIN["myregistry.azurecr.io → 10.0.2.10"]
DNS_DATA["myregistry.koreacentral.data.azurecr.io → 10.0.2.11"]
end
subgraph ACR["Azure Container Registry<br/>(Premium, public disabled)"]
LOGIN[Login server]
DATA[Data endpoint]
end
CA -->|"1. Token request (ARM audience)"| AAD[Microsoft Entra ID]
AAD -->|"2. Access token"| CA
CA -->|"3. Pull via private IP"| PE_ACR --> LOGIN
CA -->|"4. Layer download"| PE_DATA --> DATA
DNS_LOGIN -.-> PE_ACR
DNS_DATA -.-> PE_DATA
style CA fill:#0078d4,color:#fff
style ACR fill:#f0f0f0,stroke:#888
style DNS fill:#e6f2fb,stroke:#0078d4 Two DNS records are required
ACR image pulls use two endpoints: the login server (myregistry.azurecr.io) for authentication and the regional data endpoint (myregistry.<region>.data.azurecr.io) for layer downloads. Both must resolve to private IPs through the Private DNS Zone. Missing the data endpoint causes pull failures that are not obvious from the error message.
Why user-assigned managed identity?¶
| Approach | Recommended | Notes |
|---|---|---|
| User-assigned MI | ✅ Yes | Created before the app — clean IaC, no two-phase deployment |
| System-assigned MI | ⚠️ Possible | Exists only after app creation — requires two-phase deployment in IaC |
| Admin credentials (username/password) | ❌ No | Weaker operationally, manual rotation, avoid in production |
Prerequisites¶
- Container Apps environment deployed in a VNet (see VNet Integration)
- Azure CLI with Container Apps extension:
az extension add --name containerapp - Docker (for building and pushing images)
- Variables set:
export RG="rg-myapp"
export LOCATION="koreacentral"
export ENVIRONMENT_NAME="cae-myapp"
export APP_NAME="ca-myapp"
export ACR_NAME="acrmyapp" # Globally unique, alphanumeric only
export VNET_NAME="vnet-myapp"
export PE_SUBNET_ID="<private-endpoints-subnet-id>"
export UAMI_NAME="id-myapp"
Step 1: Create ACR (Premium SKU)¶
Private Endpoints require Premium SKU. Enable the data endpoint — it's required for image layer downloads over Private Link.
az acr create \
--name "$ACR_NAME" \
--resource-group "$RG" \
--location "$LOCATION" \
--sku Premium \
--public-network-enabled false \
--data-endpoint-enabled true
Enable ARM audience token authentication
Managed identity pulls require ACR to accept ARM audience tokens. Enable this explicitly — it is not on by default in all tenants:
Without this, managed identity pull attempts fail with 401 Unauthorized even when RBAC looks correct.
Step 2: Create a user-assigned managed identity¶
az identity create \
--name "$UAMI_NAME" \
--resource-group "$RG"
export UAMI_ID=$(az identity show \
--name "$UAMI_NAME" \
--resource-group "$RG" \
--query "id" \
--output tsv)
export UAMI_PRINCIPAL_ID=$(az identity show \
--name "$UAMI_NAME" \
--resource-group "$RG" \
--query "principalId" \
--output tsv)
export UAMI_CLIENT_ID=$(az identity show \
--name "$UAMI_NAME" \
--resource-group "$RG" \
--query "clientId" \
--output tsv)
Step 3: Grant AcrPull to the managed identity¶
export ACR_ID=$(az acr show \
--name "$ACR_NAME" \
--resource-group "$RG" \
--query "id" \
--output tsv)
az role assignment create \
--assignee-object-id "$UAMI_PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "AcrPull" \
--scope "$ACR_ID"
Role assignment propagation
RBAC changes take 1–5 minutes to propagate. If a container pull fails immediately after assigning the role, wait and retry the revision.
Step 4: Create the Private Endpoint for ACR¶
Create a private endpoint for the registry login server (registry group). This handles authentication and, for standard registries, layer downloads as well.
# Private Endpoint for login server
az network private-endpoint create \
--name "pe-acr-${ACR_NAME}" \
--resource-group "$RG" \
--subnet "$PE_SUBNET_ID" \
--private-connection-resource-id "$ACR_ID" \
--group-id "registry" \
--connection-name "conn-acr-registry"
Optional: Dedicated data endpoint¶
If you need separate network control over image layer downloads (e.g., for geo-replicated registries), enable the dedicated data endpoint and create a second PE. This must be done after the ACR is fully provisioned — the registry_data_<region> group ID is not immediately available and will cause a deployment failure if attempted in the same Bicep deployment as the ACR.
# Step 1: Enable dedicated data endpoint (after ACR is provisioned)
az acr update \
--name "$ACR_NAME" \
--resource-group "$RG" \
--data-endpoint-enabled true
# Step 2: Verify the data endpoint group ID is available
az network private-link-resource list \
--id "$ACR_ID" \
--output table
# Step 3: Create the data endpoint PE (using the group ID from step 2)
az network private-endpoint create \
--name "pe-acr-data-${ACR_NAME}" \
--resource-group "$RG" \
--subnet "$PE_SUBNET_ID" \
--private-connection-resource-id "$ACR_ID" \
--group-id "registry_data_${LOCATION}" \
--connection-name "conn-acr-data"
# Step 4: Add DNS zone group for data endpoint
az network private-endpoint dns-zone-group create \
--resource-group "$RG" \
--endpoint-name "pe-acr-data-${ACR_NAME}" \
--name "default" \
--private-dns-zone "privatelink.azurecr.io" \
--zone-name "registry-data"
Bicep + data endpoint race condition
Creating the data endpoint PE in the same Bicep deployment as the ACR resource will fail with InvalidPrivateEndpointConnectionRequestParameters because the registry_data_<region> group ID is not registered until the ACR fully provisions. Always create the data endpoint PE in a separate step via CLI or a second Bicep deployment.
Step 5: Configure Private DNS Zone¶
export VNET_ID=$(az network vnet show \
--name "$VNET_NAME" \
--resource-group "$RG" \
--query "id" \
--output tsv)
# Create Private DNS Zone
az network private-dns zone create \
--resource-group "$RG" \
--name "privatelink.azurecr.io"
# Link DNS Zone to VNet
az network private-dns link vnet create \
--resource-group "$RG" \
--zone-name "privatelink.azurecr.io" \
--name "link-acr-${VNET_NAME}" \
--virtual-network "$VNET_ID" \
--registration-enabled false
# Create DNS Zone Group — automatically registers A record for login server
az network private-endpoint dns-zone-group create \
--resource-group "$RG" \
--endpoint-name "pe-acr-${ACR_NAME}" \
--name "default" \
--private-dns-zone "privatelink.azurecr.io" \
--zone-name "registry"
Verify A records were registered:
az network private-dns record-set a list \
--resource-group "$RG" \
--zone-name "privatelink.azurecr.io" \
--output table
Expected output:
Name TTL Type AutoRegistered Records
------------------------------ ----- ------ ---------------- ----------
myregistry 10 A False 10.0.2.10
myregistry.koreacentral.data 10 A False 10.0.2.11
Data endpoint DNS record is registered automatically
When you create the registry group Private Endpoint and attach a DNS zone group, Azure automatically registers both A records — the login server record (myregistry) and the regional data endpoint record (myregistry.<region>.data) — in the same privatelink.azurecr.io zone. A separate data endpoint Private Endpoint is not required for standard image pulls from Container Apps. The explicit registry_data_<region> PE is only needed when you require independent network control per endpoint (e.g., for geo-replication scenarios where each replica region gets its own PE).
Step 6: Build and push the image¶
Pushing to a private ACR requires network line-of-sight. Use one of: - A VM or jumpbox inside the VNet - GitHub Actions self-hosted runner in the VNet - ACR Tasks (runs inside Azure)
# Option A: ACR Tasks (builds and pushes inside Azure, no local Docker needed)
az acr build \
--registry "$ACR_NAME" \
--image "myapp:latest" \
--file Dockerfile \
../app
# Option B: Local push (only if running inside VNet or ACR public access temporarily enabled)
az acr login --name "$ACR_NAME"
docker build -t "${ACR_NAME}.azurecr.io/myapp:latest" ../app
docker push "${ACR_NAME}.azurecr.io/myapp:latest"
CI/CD runners must have network line-of-sight
When ACR public access is disabled, GitHub-hosted runners cannot push images to the registry. Use ACR Tasks or a self-hosted runner inside the VNet.
ACR Tasks and public network access
az acr build (ACR Tasks) queues the build on Azure-managed agents with dynamic public IPs. These agents cannot reach the registry when publicNetworkAccess: Disabled. To use ACR Tasks with a fully private registry, temporarily enable public network access during the build:
# Enable public access for the build
az acr update --name "$ACR_NAME" --public-network-enabled true
az acr build --registry "$ACR_NAME" --image "myapp:latest" --file Dockerfile ../app
# Re-disable immediately after
az acr update --name "$ACR_NAME" --public-network-enabled false
For production pipelines, prefer a self-hosted runner or build agent inside the VNet to avoid toggling public access.
Step 7: Configure the Container App to use the private registry¶
Attach the user-assigned MI to the app and configure it as the registry pull identity:
# Create or update the Container App with UAMI registry auth
az containerapp create \
--name "$APP_NAME" \
--resource-group "$RG" \
--environment "$ENVIRONMENT_NAME" \
--image "${ACR_NAME}.azurecr.io/myapp:latest" \
--registry-server "${ACR_NAME}.azurecr.io" \
--registry-identity "$UAMI_ID" \
--user-assigned "$UAMI_ID" \
--env-vars AZURE_CLIENT_ID="$UAMI_CLIENT_ID" \
--ingress external \
--target-port 8000
For an existing app, update registry config:
az containerapp registry set \
--name "$APP_NAME" \
--resource-group "$RG" \
--server "${ACR_NAME}.azurecr.io" \
--identity "$UAMI_ID"
Then update the image:
az containerapp update \
--name "$APP_NAME" \
--resource-group "$RG" \
--image "${ACR_NAME}.azurecr.io/myapp:latest"
Control-plane success ≠ runtime success
az containerapp update --image updates the app spec successfully even when the registry is unreachable. The revision will fail to start if DNS resolution or RBAC is wrong. Always check revision status and logs after an image update.
Step 8: Verify¶
Check DNS resolution from inside the container¶
az containerapp exec \
--name "$APP_NAME" \
--resource-group "$RG" \
--command "nslookup ${ACR_NAME}.azurecr.io"
Expected — private IP, not public:
Server: 168.63.129.16
Address: 168.63.129.16#53
Non-authoritative answer:
myregistry.azurecr.io canonical name = myregistry.privatelink.azurecr.io.
Name: myregistry.privatelink.azurecr.io
Address: 10.0.2.10
Also verify the data endpoint:
az containerapp exec \
--name "$APP_NAME" \
--resource-group "$RG" \
--command "nslookup ${ACR_NAME}.${LOCATION}.data.azurecr.io"
Check revision status¶
Expected:
Name Active Traffic Replicas HealthState
------------------------------- -------- --------- ---------- -----------
<your-app-name>--<revision> True 100 1 Healthy
Check app logs¶
az containerapp logs show \
--name "$APP_NAME" \
--resource-group "$RG" \
--follow false \
--tail 50
Using Bicep¶
See infra/modules/acr-private.bicep for the full module.
Integration into your environment:
// User-assigned managed identity (created before app — clean IaC)
resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: 'id-${baseName}'
location: location
}
// ACR with Private Endpoint
module acr 'modules/acr-private.bicep' = {
name: 'acr-deployment'
params: {
baseName: baseName
location: location
privateEndpointSubnetId: network.outputs.privateEndpointsSubnetId
vnetId: network.outputs.vnetId
pullIdentityPrincipalId: managedIdentity.properties.principalId
}
}
// Container App references UAMI for registry pull
resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
name: 'ca-${baseName}'
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${managedIdentity.id}': {}
}
}
properties: {
// ...
configuration: {
registries: [
{
server: acr.outputs.loginServer
identity: managedIdentity.id // UAMI reference — no password
}
]
}
template: {
containers: [
{
name: 'app'
image: '${acr.outputs.loginServer}/${baseName}:${imageTag}'
env: [
{
name: 'AZURE_CLIENT_ID'
value: managedIdentity.properties.clientId
}
]
}
]
}
}
}
Operational Considerations¶
Scale-out and restarts¶
Container Apps uses an always pull policy — every new replica and every restart triggers an image pull. This means:
- Private DNS must always resolve correctly inside the VNet
- RBAC role assignment must be active at all times
- Network path (NSG, private endpoint) must remain healthy
A DNS or auth issue that surfaces on scale-out may not have been visible at initial deployment.
Rotating credentials¶
With managed identity, there are no credentials to rotate. The identity token is refreshed automatically by the platform. This is one of the main reasons to prefer MI over admin credentials.
Geo-replicated ACR¶
If ACR is geo-replicated, each replica region has its own data endpoint:
Each requires a separate Private Endpoint and DNS A record in privatelink.azurecr.io. The login server endpoint (myregistry.azurecr.io) remains a single endpoint.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Revision stuck in Provisioning | Image pull failing — check auth or DNS | az containerapp revision show → check RunningState; check logs |
401 Unauthorized on pull | ARM audience token not enabled, or RBAC not propagated | Run az acr config authentication-as-arm update --status enabled; wait 5 min for RBAC propagation |
nslookup returns public IP | Private DNS Zone not linked to VNet, or zone group not created | Verify DNS zone link and az network private-endpoint dns-zone-group exists |
| Pull succeeds but container crashes | App config issue, not registry issue | Check container logs; registry auth was fine |
az containerapp update --image succeeds, revision fails | Control-plane change succeeded but runtime pull failed | Check revision logs; confirm DNS resolves to private IP |
CI/CD push fails with connection refused | Runner cannot reach private ACR | Use ACR Tasks (az acr build) or self-hosted runner in VNet |
| Data endpoint DNS not resolving | DNS zone group not attached to registry PE | Verify az network private-endpoint dns-zone-group exists for the registry PE — both A records should auto-register |
az acr build fails with denied: client with IP ... is not allowed access | ACR public network access is disabled; ACR Tasks agents use dynamic public IPs | Temporarily enable public access for the build, or use a self-hosted runner in the VNet |
| After ACR key rotation, pull fails | Not applicable for MI auth | N/A — MI tokens are auto-refreshed |