GitHub Actions to Container Registry to Container Apps, with managed identities and no registry passwords
The build-and-run handoff for containers — GitHub Actions signs in with OIDC, pushes an immutable tag to Azure Container Registry, and updates a Container App that pulls with a user-assigned identity holding AcrPull granted before the first deployment — with Bicep for the registry, identity, role, environment and app, the workflow, the roles per side and per registry mode, known-bad configurations with their exact errors, and which side is wrong.
markdownpatternv1Q9HX-BSSYstage: buildtrust: officialservice: container-appsservice: container-registryservice: entrainterface: iacinterface: cliinterface: ciGitHub Actions Container AppsGitHub Actions ACR push OIDCaz acr login GitHub ActionsAcrPush GitHubContainer Apps ContributorContainer Apps pull ACR managed identityregistries identity BicepMicrosoft.App/containerApps@2026-01-01Microsoft.App/managedEnvironments@2026-01-01Microsoft.ContainerRegistry/registries@2025-11-01AcrPull 7f951dda-4ed3-4680-a7ca-43fe172d538dimmutable image tag commit SHAaz containerapp update --imageABAC registry AcrPush not honoredadminUserEnabled false
GitHub Actions to Container Registry to Container Apps
Two identities, two hops. The pipeline identity (a GitHub OIDC federated credential, patterns/github-actions-to-azure.md) pushes the image and updates the app. The pull identity (user-assigned, attached to the app) pulls the image when a revision starts. Neither uses a registry password; the admin user stays off.
Roles
| Identity | Scope | Legacy registry mode | ABAC registry mode |
|---|---|---|---|
| Pipeline | Registry | AcrPush | Container Registry Repository Writer |
| Pipeline | Container app (or its resource group) | Container Apps Contributor | Same |
| Pull identity | Registry | AcrPull | Container Registry Repository Reader |
Check the mode first: az acr show -n <acr> --query roleAssignmentMode -o tsv. In ABAC mode the Acr* roles are ignored.
Infrastructure (main.bicep)
API versions with Bicep 0.48.1 types: Container Apps 2026-01-01, Container Registry 2025-11-01.
param appName string = 'ca-orders'
@description('Registry name: 5-50 alphanumeric characters, globally unique.')
param registryName string
@description('Image tag to run. Use an immutable tag such as the commit SHA.')
param imageTag string
param location string = resourceGroup().location
resource pullIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: 'id-${appName}-pull'
location: location
}
resource registry 'Microsoft.ContainerRegistry/registries@2025-11-01' = {
name: registryName
location: location
sku: {
name: 'Standard'
}
properties: {
adminUserEnabled: false
anonymousPullEnabled: false
roleAssignmentMode: 'LegacyRegistryPermissions'
}
}
// AcrPull, granted before the app exists so the first pull succeeds
resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: registry
name: guid(registry.id, pullIdentity.id, 'acrpull')
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
principalId: pullIdentity.properties.principalId
principalType: 'ServicePrincipal'
}
}
resource environment 'Microsoft.App/managedEnvironments@2026-01-01' = {
name: 'cae-${appName}'
location: location
properties: {
workloadProfiles: [
{
name: 'Consumption'
workloadProfileType: 'Consumption'
}
]
}
}
resource app 'Microsoft.App/containerApps@2026-01-01' = {
name: appName
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${pullIdentity.id}': {}
}
}
properties: {
environmentId: environment.id
workloadProfileName: 'Consumption'
configuration: {
activeRevisionsMode: 'Single'
ingress: {
external: true
targetPort: 8080
transport: 'auto'
allowInsecure: false
}
registries: [
{
server: registry.properties.loginServer
identity: pullIdentity.id
}
]
}
template: {
containers: [
{
name: 'api'
image: '${registry.properties.loginServer}/orders-api:${imageTag}'
resources: {
cpu: json('0.5')
memory: '1Gi'
}
env: [
{
name: 'PORT'
value: '8080'
}
]
}
]
scale: {
minReplicas: 1
maxReplicas: 10
}
}
}
dependsOn: [
acrPull
]
}
output fqdn string = app.properties.configuration.ingress.fqdn
The first deployment needs an image that already exists in the registry (push once, or deploy with a public image and switch). minReplicas: 1 avoids cold starts; set 0 to scale to zero. Logs go nowhere until you add appLogsConfiguration with a Log Analytics workspace (observability family).
Workflow (.github/workflows/deploy.yml)
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- uses: azure/login@v3
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Build and push an immutable tag
run: |
az acr login --name ${{ vars.ACR_NAME }}
IMAGE=${{ vars.ACR_NAME }}.azurecr.io/orders-api:${{ github.sha }}
docker build -t "$IMAGE" .
docker push "$IMAGE"
- name: Roll out a new revision
run: |
az containerapp update -n ca-orders -g ${{ vars.RESOURCE_GROUP }} \
--image ${{ vars.ACR_NAME }}.azurecr.io/orders-api:${{ github.sha }}
Known-bad
| Configuration | Symptom |
|---|---|
| Pull identity is system-assigned, image is private, first deployment | Error provisioning revision … ErrorCode: [ErrImagePull]: the identity didn't exist to hold AcrPull yet |
AcrPull granted in the same deployment without dependsOn | Intermittent first-pull failures until the role propagates |
| Registry in ABAC mode, roles are AcrPull / AcrPush | Pull: unauthorized / ErrImagePull; push: insufficient_scope: authorization failed |
Pipeline reuses the tag latest | az containerapp update with the same reference doesn't re-validate the image, and you can't tell which build runs |
| Registry firewall on, environment not VNet-integrated with a private endpoint | denied: … client with IP '<ip>' is not allowed access. on pull |
| Admin user enabled and its password in a secret | Works, but a long-lived shared password; disable it once identities work |
Container listens on 3000, targetPort 8080 | Revision Degraded, 0 ready replicas (default TCP probes fail) |
Which side is wrong
- Push fails (
unauthorized,insufficient_scope) → the pipeline identity: OIDC subject, AcrPush or Repository Writer (reference/container-registry/errors.md). - Revision has no replicas and system logs show a pull error → the pull identity: attached to the app, named in
registries[].identity, holding the right role for the registry's mode. - Image pulled but revision unhealthy → the container: port, command, probes (
reference/container-apps/errors.md).