The Challenge

When managing containerized applications in Azure Container Apps, you eventually face the question: should you continue using Docker Hub, or migrate to Azure Container Registry (ACR)? For enterprise workloads, ACR offers significant advantages: integrated security, managed identities for authentication, reduced egress costs, and better integration with Azure RBAC.

This post documents a complete migration from Docker Hub to ACR across multiple Container Apps in different Azure subscriptions, including the critical managed identity configuration that enables seamless authentication without storing credentials.

Architecture Overview

Our setup consisted of:

  • Container Apps in two subscriptions (dev and prod)
  • Azure Container Registry centralized in the prod subscription
  • GitHub Actions workflows for CI/CD
  • Multiple applications (.NET 5.0 and .NET 8.0) requiring migration

The key challenge: enabling Container Apps in the dev subscription to pull images from ACR in the prod subscription without hardcoded credentials.

Step 1: Provisioning Azure Container Registry

First, create the ACR in your centralized subscription:

az account set --subscription "PROD-SUBSCRIPTION"

az acr create \
  --name mycontainerreg \
  --resource-group rg-container-registry \
  --sku Standard \
  --location centralus \
  --admin-enabled false

Note the --admin-enabled false flag. We’re deliberately avoiding admin credentials in favor of managed identities.

Step 2: Enabling Managed Identities on Container Apps

Each Container App needs a system-assigned managed identity:

# For a Container App in the dev subscription
az account set --subscription "DEV-SUBSCRIPTION"

az containerapp identity assign \
  --name my-container-app-dev \
  --resource-group my-containers-dev \
  --system-assigned

Verify the identity was created:

PRINCIPAL_ID=$(az containerapp show \
  --name my-container-app-dev \
  --resource-group my-containers-dev \
  --query identity.principalId \
  -o tsv)

echo $PRINCIPAL_ID
# Output: 8a8a9b91-3f9b-4914-bdb3-c7fd618db167

Step 3: Cross-Subscription Role Assignments

This is where it gets interesting. The Container App in the dev subscription needs permission to pull from ACR in the prod subscription.

# Switch to the prod subscription where ACR lives
az account set --subscription "PROD-SUBSCRIPTION"

ACR_ID="/subscriptions/<prod-sub-id>/resourceGroups/rg-container-registry/providers/Microsoft.ContainerRegistry/registries/mycontainerreg"

# Grant AcrPull role to the dev Container App's managed identity
az role assignment create \
  --assignee $PRINCIPAL_ID \
  --role AcrPull \
  --scope $ACR_ID

The AcrPull role is specifically designed for this use case—it allows pulling images but not pushing or administrative actions.

Step 4: Configuring Container App Registry Authentication

Now configure the Container App to use its managed identity for ACR authentication:

# Switch back to dev subscription
az account set --subscription "DEV-SUBSCRIPTION"

az containerapp registry set \
  --name my-container-app-dev \
  --resource-group my-containers-dev \
  --server mycontainerreg.azurecr.io \
  --identity system

This tells the Container App: “When you need to pull images from mycontainerreg.azurecr.io, use your system-assigned managed identity.”

Step 5: Updating GitHub Actions Workflows

Docker Build and Push

Update your workflow to push to ACR instead of Docker Hub:

build-container:
  runs-on: ubuntu-latest
  env:
    DOCKER_TAG: ${{ github.sha }}
  steps:
    - name: Checkout
      uses: actions/checkout@v4

    - name: Build container image
      run: docker compose -f ./deployment/docker/docker-compose.yml build
      shell: bash

    - uses: docker/login-action@v3
      with:
        registry: ${{ secrets.ACR_LOGIN_SERVER }}
        username: ${{ secrets.ACR_USERNAME }}
        password: ${{ secrets.ACR_PASSWORD }}

    - name: Push docker image to ACR
      run: docker push ${{ secrets.ACR_LOGIN_SERVER }}/${{ github.event.repository.name }}:${{ env.DOCKER_TAG }}
      shell: bash

Container App Deployment

The critical addition here is the acrName parameter:

production-deploy:
  runs-on: ubuntu-latest
  env:
    DOCKER_TAG: ${{ github.sha }}
  needs: build-container
    
  steps:
    - name: Login to Azure
      uses: azure/login@v2
      with:
        creds: '{
          "clientId": "${{secrets.AZURE_CLIENT_ID}}",
          "clientSecret": "${{secrets.AZURE_CLIENT_SECRET}}",
          "subscriptionId": "${{secrets.AZURE_SUBSCRIPTION_PROD}}",
          "tenantId": "${{secrets.AZURE_TENANT}}"}'

    - name: Build and deploy Container App
      uses: azure/container-apps-deploy-action@v1
      with:
        acrName: mycontainerreg  # CRITICAL: Enables ACR authentication
        containerAppName: my-container-app-prod
        resourceGroup: my-containers-prod
        imageToDeploy: ${{ secrets.ACR_LOGIN_SERVER }}/${{github.event.repository.name}}:${{env.DOCKER_TAG}}
        containerAppEnvironment: 'Prod'
        environmentVariables: 'VAULT_ROLE_ID=secretref:vault-role-id
        VAULT_SECRET_ID=secretref:vault-secret-id
        CRDS_ENV=prod'

Why acrName matters: Without this parameter, the Container Apps deployment action can’t properly authenticate to ACR using the Azure login credentials. This parameter bridges the gap between your service principal and ACR.

Step 6: Updating docker-compose.yml Files

A subtle but critical issue: your docker-compose.yml must build with the ACR image tag, not Docker Hub:

Before:

services:
  app:
    image: dockerhub-org/my-app:${DOCKER_TAG:-local}
    build:
      context: ../../
      dockerfile: ./deployment/docker/Dockerfile

After:

services:
  app:
    image: mycontainerreg.azurecr.io/my-app:${DOCKER_TAG:-local}
    build:
      context: ../../
      dockerfile: ./deployment/docker/Dockerfile

Without this change, Docker builds the image with the wrong tag, and the subsequent push fails with:

An image does not exist locally with the tag: mycontainerreg.azurecr.io/my-app

Step 7: Service Principal Permissions for GitHub Actions

Your GitHub Actions service principal needs AcrPull permission as well (for pushing during builds):

az account set --subscription "PROD-SUBSCRIPTION"

az role assignment create \
  --assignee <service-principal-app-id> \
  --role AcrPull \
  --scope $ACR_ID

This service principal is typically shared across all your workflows (dev, int, prod), so a single role assignment covers all environments.

Special Case: Debian Buster Archives

If you’re using older .NET versions (like .NET 5.0) that depend on Debian Buster, you’ll hit a critical issue: Debian Buster was archived in August 2024. Your Dockerfile will fail with:

404 Not Found [IP: 151.101.xxx.xxx 80]

Solution: Update your Dockerfile to point to the Debian archive:

FROM mcr.microsoft.com/dotnet/aspnet:5.0-buster-slim

# Install wget
RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list \
&& sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \
&& sed -i '/buster-updates/d' /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y wget

# Install gnupg
RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list \
&& sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \
&& sed -i '/buster-updates/d' /etc/apt/sources.list \
&& apt-get install -y gnupg

# Install New Relic (or other apt packages)
RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list \
&& sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \
&& sed -i '/buster-updates/d' /etc/apt/sources.list \
&& echo 'deb http://apt.newrelic.com/debian/ newrelic non-free' | tee /etc/apt/sources.list.d/newrelic.list \
&& wget -O- https://download.newrelic.com/548C16BF.gpg | apt-key add - \
&& apt-get update \
&& apt-get install newrelic-netcore20-agent

Important: Apply these sed commands before every apt-get command that needs to access Debian repositories. The sources.list gets reset with certain operations.

Note: .NET 8.0 and later don’t need these fixes—they use newer base images that aren’t affected.

GitHub Secrets Configuration

Set these organization-level secrets in GitHub:

ACR_LOGIN_SERVER: mycontainerreg.azurecr.io
ACR_USERNAME: <token-name>
ACR_PASSWORD: <token-password>

For the password, generate an ACR token:

az acr token create \
  --name github-push-token \
  --registry mycontainerreg \
  --scope-map _repositories_push

# Get the password
az acr token credential generate \
  --name github-push-token \
  --registry mycontainerreg

Critical: Store the plain text password in GitHub Secrets, not base64-encoded.

Verification and Troubleshooting

Checking Managed Identity Configuration

Verify a Container App is configured correctly:

az containerapp show \
  --name my-container-app-dev \
  --resource-group my-containers-dev \
  --query "{identity: identity, registries: properties.configuration.registries}" \
  -o json

Expected output:

{
  "identity": {
    "principalId": "8a8a9b91-3f9b-4914-bdb3-c7fd618db167",
    "type": "SystemAssigned"
  },
  "registries": [
    {
      "identity": "system",
      "server": "mycontainerreg.azurecr.io"
    }
  ]
}

Common Issues

Issue: Container App stuck in “Activating” state

Cause: Usually indicates the container can’t start, for me this was due to New Relic agent path mismatch that I missed:

Solution: Check console logs:

az containerapp logs show \
  --name my-container-app-prod \
  --resource-group my-containers-prod \
  --tail 50

Issue: “An image does not exist locally with the tag”

Cause: docker-compose.yml building with Docker Hub tag, workflow pushing to ACR tag.

Solution: Update docker-compose.yml image reference to ACR format.

Issue: “AuthorizationFailed” during deployment

Cause: Missing role assignments or wrong scope.

Solution: Verify role assignments:

az role assignment list \
  --assignee $PRINCIPAL_ID \
  --scope $ACR_ID \
  -o table

Migration Checklist

  • Create ACR in centralized subscription
  • Enable system-assigned managed identity on each Container App
  • Grant AcrPull role to each managed identity (cross-subscription if needed)
  • Configure Container App registry authentication with --identity system
  • Grant AcrPull role to GitHub Actions service principal
  • Update GitHub Actions workflows:
    • Change docker login to ACR
    • Update image push commands
    • Add acrName parameter to deployment steps
  • Update docker-compose.yml files with ACR image tags
  • Apply Debian Buster archive fixes (if using .NET 5.0 or older)
  • Test deployment in dev environment
  • Apply same changes to prod
  • Remove Docker Hub credentials after successful migration

Performance and Cost Benefits

After migration, we observed:

  • Reduced latency: Image pulls from ACR in the same region are significantly faster than Docker Hub
  • Lower egress costs: No charges for traffic within Azure regions
  • Better reliability: No Docker Hub rate limiting issues
  • Improved security: No stored credentials, all authentication via managed identities
  • Simplified compliance: All container images under organizational control

Conclusion

Migrating from Docker Hub to Azure Container Registry for Container Apps involves several moving parts, but the security and operational benefits are substantial. The key insight is leveraging managed identities for authentication—eliminating credential management while enabling secure cross-subscription access.

The most critical configuration points are:

  1. Proper role assignments with the correct scope
  2. The acrName parameter in deployment actions
  3. Matching image tags between build and push operations
  4. Handling deprecated base images (Debian Buster)

With these pieces in place, you get a fully integrated, secure container deployment pipeline that leverages Azure’s native authentication mechanisms.

Additional Resources