---
cuppy_hub: azure
cuppy_path: patterns/github-actions-to-container-apps.md
cuppy_section: docs
cuppy_doc: Q9HX-BSSY
cuppy_doc_uuid: baef7e2f-2c13-429b-accc-0981fb72b17f
cuppy_permalink: https://cuppy.io/azure/d/Q9HX-BSSY
cuppy_version: 1
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/azure/docs/patterns/github-actions-to-container-apps
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: 51fbdf3937af2b76c0377df8fed4687f041433ca5d7d0645eeadb50809eb14cc
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# 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.

```bicep
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`)

```yaml
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`).


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 107 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/azure/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
