Writing

Pushing Docker Images to Azure Container Registry with GitHub Actions

Set up a GitHub Actions workflow to build and push Docker images to ACR. Spoiler: the OIDC token approach doesn't work with buildx. Here's what does.

3 min read557 wordsAzureDockerGitHub ActionsDevOps

I needed to build 4 Docker images from a monorepo and push them to Azure Container Registry on every push to main. Sounds simple. It wasn't.

What I was going for

Four services, four images, all pushed to wira360.azurecr.io automatically. Tags with the git SHA for traceability, plus latest for convenience. No stored secrets. OIDC for auth.

The workflow (final version)

name: Build and Push to ACR
 
on:
  push:
    branches: [main]
  workflow_dispatch:
 
jobs:
  build-push:
    name: ${{ matrix.service }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - service: sokoni-api
            dockerfile: products/sokoni/api/Dockerfile
          - service: sokoni-web
            dockerfile: products/sokoni/web/Dockerfile
          - service: payments-api
            dockerfile: services/payments/api/Dockerfile
          - service: admin-web
            dockerfile: platform/admin/web/Dockerfile
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to ACR
        uses: docker/login-action@v3
        with:
          registry: wira360.azurecr.io
          username: \${{ secrets.ACR_USERNAME }}
          password: \${{ secrets.ACR_PASSWORD }}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: \${{ matrix.dockerfile }}
          push: true
          tags: |
            wira360.azurecr.io/\${{ matrix.service }}:\${{ github.sha }}
            wira360.azurecr.io/\${{ matrix.service }}:latest
          cache-from: type=registry,ref=wira360.azurecr.io/\${{ matrix.service }}:cache
          cache-to: type=registry,ref=wira360.azurecr.io/\${{ matrix.service }}:cache,mode=max

fail-fast: false means a slow build in one job won't cancel the others. The registry cache means repeat builds only transfer changed layers.

One-time Azure setup

Before this can run you need an ACR. I already had one at wira360.azurecr.io. Enable admin credentials:

az acr update --name wira360 --admin-enabled true
az acr credential show --name wira360

That gives you a username and password. Store them as GitHub secrets:

  • ACR_USERNAME
  • ACR_PASSWORD

The OIDC rabbit hole

I originally wanted keyless auth. No stored secrets, just OIDC. The setup looks clean: create an app registration, add a federated credential pointing at your repo, grant it AcrPush, and use azure/login@v2 in the workflow.

az ad app create --display-name "github-tracker-acr"
az ad sp create --id <appId>
 
az ad app federated-credential create \
  --id <appId> \
  --parameters '{
    "name": "github-tracker-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:eduuh/wira360:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'
 
ACR_ID=$(az acr show --name wira360 --query id -o tsv)
az role assignment create --assignee <appId> --role AcrPush --scope $ACR_ID
Audience gotcha

The audience must be api://AzureADTokenExchange. If you use api://AzureADTokenEndpoint (which looks plausible), OIDC silently fails with AADSTS700212.

OIDC login worked. But the Docker push kept failing with insufficient_scope: authorization failed.

The problem: after azure/login@v2 authenticates you, the next step is getting Docker credentials for the registry. The obvious approach is az acr login --name wira360, which writes credentials to the default Docker context. Except buildx runs in its own container driver and doesn't inherit those credentials.

So you try az acr login --expose-token, get a short-lived access token, and pass it to docker login. Closer, but still fails. The reason: buildx's OAuth challenge flow with ACR expects either basic auth credentials or a refresh token. A bare access token doesn't satisfy the challenge, so ACR returns insufficient_scope.

There are ways to make OIDC work with buildx (passing the token through buildx secrets, using a different driver), but they add complexity for marginal benefit. Admin credentials stored as GitHub secrets are static, they work, and that's enough.

Verifying it worked

az acr repository list --name wira360
az acr repository show-tags --name wira360 --repository sokoni-api

You should see your git SHAs listed as tags.


OIDC is elegant in theory. Admin credentials are boring and they work.

Last updated on February 22nd, 2026