Microsoft maintains first-party GitHub Actions for Azure — azure/login, azure/aks-set-context, azure/docker-login — which makes this pipeline cleaner to wire up than most other cloud/CI combinations. Here's the version I run for shipping a containerized service to Azure Kubernetes Service.

Set up OIDC federation before writing the pipeline

Skip storing a service principal secret entirely. Create a federated credential on an Azure AD App Registration scoped to this exact repo and branch:

az ad app federated-credential create \
  --id <app-registration-id> \
  --parameters '{
    "name": "github-actions-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:my-org/my-app:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

Grant that App Registration's service principal exactly the roles the pipeline needs — AcrPush on the registry, Azure Kubernetes Service Cluster User Role on the cluster — never subscription-wide Contributor.

The pipeline

name: Deploy to AKS

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

env:
  REGISTRY: myregistry.azurecr.io
  IMAGE: my-app
  RESOURCE_GROUP: prod-rg
  CLUSTER_NAME: prod-aks

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC, no secret)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Log in to Azure Container Registry
        run: az acr login --name myregistry

      - name: Build and push image
        run: |
          IMAGE_TAG=${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
          docker build -t $IMAGE_TAG .
          docker push $IMAGE_TAG
          echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV

      - name: Set AKS context
        uses: azure/aks-set-context@v4
        with:
          resource-group: ${{ env.RESOURCE_GROUP }}
          cluster-name: ${{ env.CLUSTER_NAME }}

      - name: Roll out the new image
        run: |
          kubectl set image deployment/my-app \
            my-app=${{ env.IMAGE_TAG }} \
            -n production
          kubectl rollout status deployment/my-app -n production --timeout=120s

Two details doing real work:

  • kubectl rollout status blocks the job until the rollout genuinely succeeds or times out. Without it, a broken image reports a "successful" deploy and fails silently in production five minutes later.
  • Tagging with github.sha rather than latest means every deployed image traces back to an exact commit — the difference between "what changed" being a five-second lookup versus an afternoon during an incident.

Gating production behind a manual approval

For anything beyond a personal project, split the workflow so main auto-deploys to staging and production requires an explicit promotion, using GitHub Environments:

  deploy-production:
    needs: build-and-deploy
    runs-on: ubuntu-latest
    environment: production # configure required reviewers on this environment
    steps:
      - uses: azure/aks-set-context@v4
        with:
          resource-group: prod-rg
          cluster-name: prod-aks
      - name: Promote to production
        run: |
          kubectl set image deployment/my-app \
            my-app=${{ needs.build-and-deploy.outputs.image_tag }} \
            -n production

Handling the ACR-to-AKS pull permission correctly

A step people forget until the first deploy fails: AKS needs explicit permission to pull from ACR unless it was wired up at cluster creation. Attach it once, and it's handled for every future deploy — no per-pipeline credential needed:

az aks update \
  --name prod-aks \
  --resource-group prod-rg \
  --attach-acr myregistry

What this buys you over doing it by hand

Once this pipeline exists, a deploy is a git push, not a person running kubectl from memory at 6 p.m. on a Friday. The deployment mechanics here stay identical regardless of how the AKS cluster itself is provisioned or scaled — that's covered separately in AKS vs. self-managed Kubernetes.