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

Created 21 Oct 2026 · Modified 23 Oct 2026 · 1 version

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

IdentityScopeLegacy registry modeABAC registry mode
PipelineRegistryAcrPushContainer Registry Repository Writer
PipelineContainer app (or its resource group)Container Apps ContributorSame
Pull identityRegistryAcrPullContainer 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

ConfigurationSymptom
Pull identity is system-assigned, image is private, first deploymentError provisioning revision … ErrorCode: [ErrImagePull]: the identity didn't exist to hold AcrPull yet
AcrPull granted in the same deployment without dependsOnIntermittent first-pull failures until the role propagates
Registry in ABAC mode, roles are AcrPull / AcrPushPull: unauthorized / ErrImagePull; push: insufficient_scope: authorization failed
Pipeline reuses the tag latestaz 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 endpointdenied: … client with IP '<ip>' is not allowed access. on pull
Admin user enabled and its password in a secretWorks, but a long-lived shared password; disable it once identities work
Container listens on 3000, targetPort 8080Revision 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).

Read this document as markdown — the same bytes an agent receives.