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 statusblocks 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.sharather thanlatestmeans 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.