Push Docker Images to Azure Container Registry with GitHub Actions (No Secrets)

TQ
Tran Quang
September 24, 2026 ยท 9 min read
#Azure Container Registry#GitHub Actions#OIDC#Docker

You have a Dockerfile, a GitHub repository and an Azure Container Registry, and you want every merge to main to produce an image in the registry. The usual first attempt is to enable the registry admin user or create a service principal secret, paste it into GitHub secrets, and call docker login. It works, but now you own a long-lived credential that can be leaked from logs, forks or a compromised action, and that has to be rotated before it silently expires.

This guide shows the setup that avoids stored credentials entirely: GitHub Actions requests a short-lived OpenID Connect (OIDC) token, Microsoft Entra ID exchanges it for an Azure access token, and that token is used to push to ACR. If you need a refresher on registries, login servers and SKUs first, start with Introduction to Azure Container Registry.

How the OIDC flow works

Every GitHub Actions job can request a signed JWT from GitHub's OIDC provider (https://token.actions.githubusercontent.com). The token contains claims describing the run: the repository, the branch or tag, the environment, and whether it was triggered by a pull request. The sub (subject) claim combines these, for example repo:my-org/orders-api:ref:refs/heads/main.

On the Azure side you create a federated identity credential on either a user-assigned managed identity or an app registration. That credential says: "trust tokens from this issuer, with this exact subject, for this audience." When azure/login presents the GitHub token, Entra ID checks the issuer, subject and audience against the federated credentials. If one matches, it issues an access token for the identity. No password is stored anywhere; both the GitHub token and the resulting Azure token are short-lived.

The practical consequence is that the subject must match exactly. A credential for refs/heads/main will not work for a pull request run, a tag push or a job that targets an environment. Most failures in this setup come down to that single string.

Choose an identity: managed identity or app registration

Both work with azure/login@v2; the difference is who manages them and where they live. Use one identity per repository, so a compromised workflow can only push what that repository owns.

Option Where it lives Permissions needed to create Good fit
User-assigned managed identity An Azure resource in a resource group Contributor (or Managed Identity Contributor) on the resource group Teams that manage everything in Azure RBAC and IaC; no Entra app admin rights needed
App registration + service principal Microsoft Entra ID tenant Rights to create applications in the tenant Organizations that already govern CI identities as app registrations

A user-assigned managed identity is usually simpler: a normal Azure resource you can deploy with Bicep or Terraform next to the registry.

Option A: user-assigned managed identity

bash
RG=rg-apps
ACR=crappsdemo
IDENTITY=id-github-crappsdemo
GH_REPO=my-org/orders-api

az identity create --resource-group $RG --name $IDENTITY

az identity federated-credential create \
  --resource-group $RG \
  --identity-name $IDENTITY \
  --name github-main \
  --issuer https://token.actions.githubusercontent.com \
  --subject repo:$GH_REPO:ref:refs/heads/main \
  --audiences api://AzureADTokenExchange

CLIENT_ID=$(az identity show -g $RG -n $IDENTITY --query clientId -o tsv)
PRINCIPAL_ID=$(az identity show -g $RG -n $IDENTITY --query principalId -o tsv)

Option B: app registration

bash
APP_ID=$(az ad app create --display-name gh-orders-api-acr --query appId -o tsv)
az ad sp create --id $APP_ID
PRINCIPAL_ID=$(az ad sp show --id $APP_ID --query id -o tsv)

cat > fic-main.json <<'EOF'
{
  "name": "github-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/orders-api:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"],
  "description": "Pushes from the main branch"
}
EOF

az ad app federated-credential create --id $APP_ID --parameters fic-main.json
CLIENT_ID=$APP_ID

Subjects you will actually need

Create one federated credential per trigger you use. Common subjects:

text
repo:my-org/orders-api:ref:refs/heads/main        # push to main
repo:my-org/orders-api:environment:production     # any job with environment: production
repo:my-org/orders-api:pull_request               # pull_request events
repo:my-org/orders-api:ref:refs/tags/v1.4.0       # one specific tag (exact match only)

Standard federated credentials do not support wildcards, so a credential per tag is impractical. The clean pattern for release tags is to run the push job with environment: release and create a single credential for repo:my-org/orders-api:environment:release. Environments also add protection rules and required reviewers. There is also a cap on federated credentials per identity; check the current docs before designing one identity per branch.

Grant AcrPush on the registry, nothing more

The identity needs the AcrPush role scoped to the registry. AcrPush includes pull, so you do not need AcrPull as well. Do not grant Contributor on the resource group "to make it work"; that lets a compromised workflow delete the registry.

bash
ACR_ID=$(az acr show --name $ACR --resource-group $RG --query id -o tsv)

az role assignment create \
  --assignee-object-id $PRINCIPAL_ID \
  --assignee-principal-type ServicePrincipal \
  --role AcrPush \
  --scope $ACR_ID

Passing --assignee-object-id with --assignee-principal-type avoids a Graph lookup that can fail for brand-new principals. Role assignments can take a few minutes to take effect.

If your registry is configured for the newer repository-level ABAC permissions mode, the built-in data-plane roles are different and scoped per repository; check the ACR role documentation for the current names before assigning.

While you are here, confirm the admin user is disabled. The admin account is a single shared username and password with full push and pull rights, it cannot be scoped to a repository, every action is audited as the same user, and rotating it breaks every consumer at once. With OIDC and RBAC you never need it.

bash
az acr update --name $ACR --admin-enabled false

The workflow: login, build, tag, push

Store the three identifiers as repository or environment variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID). They are not secrets, although storing them as secrets is harmless. Get the tenant and subscription with az account show --query "{tenant:tenantId, sub:id}".

yaml
name: build-and-push

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]
  pull_request:

permissions:
  id-token: write   # required to request the OIDC token
  contents: read

env:
  REGISTRY: crappsdemo.azurecr.io
  IMAGE: orders-api

jobs:
  build:
    if: github.ref_type != 'tag'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: azure/login@v2
        if: github.event_name != 'pull_request'
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Log in to ACR
        if: github.event_name != 'pull_request'
        run: az acr login --name crappsdemo

      - uses: docker/setup-buildx-action@v3

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
          tags: |
            type=sha,format=long
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}

      - uses: docker/build-push-action@v6
        with:
          context: .
          file: src/Orders.Api/Dockerfile
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

A few decisions in this file are deliberate:

  • permissions: id-token: write is mandatory. Without it azure/login fails because the runner never receives the OIDC request URL.
  • Pull requests build but do not push and do not log in. Pull requests from forks cannot request an OIDC token anyway, and you do not want unreviewed code writing to your registry.
  • az acr login exchanges the Azure token for a registry refresh token and writes it into the Docker config, which Buildx reads. The registry token is short-lived; for very long builds, log in right before the push step.
  • The tag trigger is listed so that docker/metadata-action produces semver tags, but a tag push has the subject ref:refs/tags/v1.4.0, which the main credential will not match. Handle tags with a dedicated job, shown next.

A release job for tags

Split tag builds into their own job bound to the release environment. The subject then becomes repo:my-org/orders-api:environment:release, matching the single credential created earlier, and the environment's protection rules apply before anything is pushed. The if conditions on the two jobs keep them from overlapping.

yaml
  release:
    if: github.ref_type == 'tag'
    runs-on: ubuntu-latest
    environment: release
    steps:
      # same steps as the build job: checkout, azure/login, az acr login,
      # setup-buildx, metadata, build-push with push: true
      - uses: actions/checkout@v4

Tagging strategy: SHA plus semver

Deploy by immutable identifiers. The full git SHA tag (sha-<40 chars>) maps every image to exactly one commit, so a deployment manifest pinned to it is reproducible and auditable. Semver tags (1.4.0, 1.4) are for humans and release notes. Avoid deploying latest or branch tags to production: they move, so two nodes can run different code under the same tag. Record the digest output of docker/build-push-action and deploy by digest where you can, and lock release tags after pushing with az acr repository update --name crappsdemo --image orders-api:1.4.0 --write-enabled false.

When the runner cannot use the Docker config

Some tools do not read the credentials written by az acr login. In that case expose the token and hand it to docker/login-action@v3. The username for an ACR access token is always the all-zeros GUID.

yaml
      - name: Get ACR token
        id: acr
        run: |
          TOKEN=$(az acr login --name crappsdemo --expose-token --query accessToken -o tsv)
          echo "::add-mask::$TOKEN"
          echo "token=$TOKEN" >> "$GITHUB_OUTPUT"

      - uses: docker/login-action@v3
        with:
          registry: crappsdemo.azurecr.io
          username: 00000000-0000-0000-0000-000000000000
          password: ${{ steps.acr.outputs.token }}

Build cache that actually hits

GitHub-hosted runners start empty. type=gha stores BuildKit layers in the GitHub Actions cache, and mode=max also caches intermediate stages of multi-stage builds, which is where the expensive dotnet restore layer lives.

The GitHub Actions cache has a per-repository size budget and evicts old entries, so large monorepos may prefer a registry cache stored in ACR:

yaml
          cache-from: type=registry,ref=crappsdemo.azurecr.io/orders-api:buildcache
          cache-to: type=registry,ref=crappsdemo.azurecr.io/orders-api:buildcache,mode=max,image-manifest=true,oci-mediatypes=true

The image-manifest=true option stores the cache as a regular OCI image manifest, which is the most compatible format for registries. Exclude the buildcache tag from any retention policy or purge job, or your cache will disappear overnight.

Whichever backend you use, the cache only helps if the Dockerfile is ordered correctly: copy project files and restore first, then copy the rest of the source.

Multi-architecture images with Buildx

If you run Arm64 node pools or Arm64 VM sizes alongside x64, publish a single tag that contains both architectures. The easiest route is QEMU emulation, but compiling .NET under emulation is slow. A better pattern is to let the SDK stage run on the runner's native platform and cross-compile for the target architecture.

dockerfile
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
WORKDIR /src
COPY src/Orders.Api/Orders.Api.csproj src/Orders.Api/
RUN dotnet restore src/Orders.Api/Orders.Api.csproj -a $TARGETARCH
COPY . .
RUN dotnet publish src/Orders.Api/Orders.Api.csproj -c Release -a $TARGETARCH --no-restore -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["dotnet", "Orders.Api.dll"]

Then add the platforms to the build step. Keep docker/setup-qemu-action@v3 in the job if any RUN instruction executes inside the final (target-platform) stage.

yaml
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          file: src/Orders.Api/Dockerfile
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}

Verify the result with docker buildx imagetools inspect crappsdemo.azurecr.io/orders-api:1.4.0; you should see one manifest per platform under a single index.

Alternative: let ACR build the image with az acr build

Instead of building on the runner, you can upload the source context and let ACR Tasks build and push inside Azure. No Docker daemon or Buildx is involved.

yaml
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Build in ACR
        run: |
          az acr build \
            --registry crappsdemo \
            --image orders-api:sha-${{ github.sha }} \
            --file src/Orders.Api/Dockerfile \
            --platform linux/amd64 \
            .

Two trade-offs. First, AcrPush is not enough: queuing a build requires the Microsoft.ContainerRegistry/registries/scheduleRun/action permission, which AcrPush does not include. Grant a role that contains it (check the ACR built-in roles list, or use a narrow custom role) rather than Contributor. Second, you lose the GitHub Actions cache and Buildx features, so large builds can be slower.

Troubleshooting login and push errors

Error Likely cause Fix
AADSTS70021: No matching federated identity record found for presented assertion (or AADSTS700213) The token's subject does not match any federated credential: wrong branch, job uses an environment, pull request event, tag push, renamed org or repo Print the subject in the failing run (see below) and create a credential for that exact string
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable id-token: write missing at workflow or job level, or a fork PR Add the permission; do not push from fork PRs
unauthorized: authentication required Docker has no valid credentials for this login server: az acr login skipped, run in another job, token expired, or image name uses a different registry host Log in in the same job, right before push; make sure tags start with crappsdemo.azurecr.io/
denied: requested access to the resource is denied Authenticated but not authorized: role missing, AcrPull instead of AcrPush, assignment on the wrong registry, not yet propagated Assign AcrPush on the registry scope, wait a few minutes, re-run
denied: client with IP '...' is not allowed access Registry firewall or public network access disabled Use a self-hosted runner in the VNet, or az acr build with trusted services; see the security guide
AuthorizationFailed ... scheduleRun/action az acr build with only AcrPush Grant a role that includes scheduleRun

Seeing the real subject

When the subject is in doubt, have the workflow print its claims (never the token itself):

yaml
      - name: Show OIDC subject
        uses: actions/github-script@v7
        with:
          script: |
            const token = await core.getIDToken('api://AzureADTokenExchange');
            const claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
            core.info(`sub=${claims.sub} aud=${claims.aud} iss=${claims.iss}`);

Also check whether your organization customised the OIDC subject template (for example to include repository_id); if so, the default subject formats above do not apply. Then compare against what Azure has:

bash
az identity federated-credential list -g rg-apps --identity-name id-github-crappsdemo \
  --query "[].{name:name, subject:subject}" -o table

Conclusion

OIDC removes the weakest part of most container pipelines: the long-lived registry password. The setup is three pieces: a federated credential whose subject exactly matches how the workflow runs, an AcrPush role assignment scoped to the registry, and a workflow with id-token: write that runs azure/login and az acr login before pushing. From there, tag by git SHA for deployments and semver for releases, cache layers so builds stay fast, and add Buildx platforms when you need Arm64. When something fails, read the error literally: AADSTS70021 is a subject mismatch, authentication required means Docker has no credentials, and denied means the identity lacks the right role.

Found this useful?

Share it with someone who might need it.

Related articles All articles