CI/CD: GitHub Actions to AKS
Why this matters
Every deploy so far in this track has been you, typing docker build,
docker push, and kubectl apply/kubectl set image by hand. That's
fine for learning, but it doesn't scale to a team, and it's easy to
forget a step or push a stale tag under pressure. This module wires up a
GitHub Actions workflow that does exactly the steps you've been doing
manually — build, push to ACR, roll out to AKS — automatically on every
push to main, so "deploy" becomes "merge," and the actual mechanics get
consistent and auditable.
Concepts
What a GitHub Actions workflow is, briefly. A YAML file in
.github/workflows/ in your repo that GitHub runs on specified triggers
(here: push to main), executing a sequence of steps on a
GitHub-hosted (or self-hosted) runner VM. Each step is either a shell
command or a reusable "action" (a packaged step someone else wrote, like
azure/login).
Two ways for the workflow to authenticate to Azure: service principal vs. OIDC federation.
- A service principal (SP) is an Azure AD application identity with a
password/certificate you generate once (
az ad sp create-for-rbac) and store as a long-lived secret in GitHub (AZURE_CREDENTIALS). The workflow logs in using that stored secret. Simple to set up, but it's a standing credential that can leak from GitHub secrets and must be manually rotated. - OIDC (OpenID Connect) federation configures Azure AD to trust GitHub's own token issuer for a specific repo/branch, so GitHub Actions requests a short-lived token at run time and Azure AD verifies it against the trust relationship — no password or secret is stored in GitHub at all. This is the modern, recommended approach: nothing long-lived to leak, nothing to rotate, and the trust is scoped to exactly the repo (and optionally branch) you configure.
This module sets up OIDC as the primary path and explains the SP approach only for comparison — prefer OIDC for any new setup.
What the workflow actually does, step by step: checkout code → log in
to Azure (via OIDC) → log in to ACR → build the Docker image → push it to
ACR tagged with the Git commit SHA (not latest — you want every deploy
traceable to an exact commit) → get AKS credentials → update the running
Deployment to the new image tag (kubectl set image, or re-applying a
manifest/Helm chart with the new tag templated in) → wait for the
rollout to finish and fail the workflow if it doesn't.
Why tag by commit SHA, not latest. kubectl set image (or any
redeploy) only actually triggers a rolling update if the image reference
changes. If every push builds and pushes myapp:latest, the Deployment's
pod spec never changes text, so Kubernetes has no way to know a new image
exists and won't roll anything out. Tagging with the immutable commit SHA
guarantees each push produces a distinct, traceable image reference that
forces a real rollout.
What AKS/Azure manages vs. what you own here: Azure AD handles OIDC
token validation once the federated credential trust is configured — you
don't manage any secret rotation for that path. You still own: the
workflow YAML itself, which branch/environment triggers a deploy, the
image tagging scheme, and — importantly — verifying the rollout actually
succeeded (a broken image that starts but crash-loops will not
necessarily fail a naive kubectl apply step unless you also check
rollout status).
Command reference
| Command | What it does | Example |
|---|---|---|
az ad app create |
Creates an Azure AD application (identity) to federate with GitHub OIDC | az ad app create --display-name gh-actions-aks-learn |
az ad sp create-for-rbac |
Creates a service principal with a password/cert (the older, non-OIDC auth path) | az ad sp create-for-rbac --name gh-actions-aks-learn --role contributor --scopes <resource-group-id> --sdk-auth |
az ad app federated-credential create |
Adds a federated identity credential trusting GitHub's OIDC issuer for a specific repo/branch | az ad app federated-credential create --id <app-id> --parameters credential.json |
az role assignment create |
Grants the app's service principal a role (e.g. Contributor or Azure Kubernetes Service Cluster User Role) scoped to your resource group/cluster |
az role assignment create --assignee <app-id> --role "Azure Kubernetes Service Cluster User Role" --scope <cluster-resource-id> |
az aks update --attach-acr |
(From module 03) still how the cluster pulls images; the workflow instead needs its own push rights on the ACR | az aks update --resource-group rg-aks-learn --name aks-learn --attach-acr acraskslearn |
az role assignment create --role AcrPush |
Grants the workflow's identity push rights on the ACR (separate from the cluster's AcrPull) |
az role assignment create --assignee <app-id> --role AcrPush --scope <acr-resource-id> |
azure/login@v2 (GitHub Action) |
Authenticates the workflow to Azure using OIDC | uses: azure/login@v2 with client-id/tenant-id/subscription-id inputs |
az acr build / docker build + docker push |
Builds and pushes the image from the workflow | docker push acraskslearn.azurecr.io/demo-app:${{ github.sha }} |
az aks get-credentials |
Fetches kubeconfig inside the workflow runner (via the azure/aks-set-context action or the raw CLI call) |
az aks get-credentials --resource-group rg-aks-learn --name aks-learn |
kubectl set image |
Updates a Deployment's container image and triggers a rolling update | kubectl set image deployment/demo-app demo-app=acraskslearn.azurecr.io/demo-app:${{ github.sha }} |
kubectl rollout status |
Blocks until a rollout completes or fails, giving CI a real pass/fail signal | kubectl rollout status deployment/demo-app --timeout=180s |
Hands-on exercises
Create an Azure AD app registration for GitHub OIDC. Run
az ad app create --display-name gh-actions-aks-learnand note the returnedappId. Also create a service principal for it if one wasn't auto-created:az ad sp create-for-rbac --name gh-actions-aks-learnis not needed for the OIDC path (that command is for the SP-secret path) — instead confirm a service principal exists for the app withaz ad sp show --id <appId>(create one withaz ad sp create --id <appId>if it doesn't).Add a federated credential trusting your GitHub repo's
mainbranch. Write a small JSON file:{ "name": "gh-actions-main", "issuer": "https://token.actions.githubusercontent.com", "subject": "repo:<your-org>/<your-repo>:ref:refs/heads/main", "audiences": ["api://AzureADTokenExchange"] }Apply it:
az ad app federated-credential create --id <appId> --parameters credential.json. Verify:az ad app federated-credential list --id <appId> --output tableshows the entry.Grant the app the roles it needs. It needs
AcrPushon your ACR andAzure Kubernetes Service Cluster User Role(orContributor, more broadly, for simplicity in a learning context) on the AKS cluster:az role assignment create --assignee <appId> --role AcrPush \ --scope $(az acr show --name acraskslearn --query id -o tsv) az role assignment create --assignee <appId> --role "Azure Kubernetes Service Cluster User Role" \ --scope $(az aks show --resource-group rg-aks-learn --name aks-learn --query id -o tsv)Verify with
az role assignment list --assignee <appId> --output table— both roles listed.Store the non-secret identifiers as GitHub repo secrets/variables. In your GitHub repo settings → Secrets and variables → Actions, add
AZURE_CLIENT_ID(theappId),AZURE_TENANT_ID(az account show --query tenantId -o tsv), andAZURE_SUBSCRIPTION_ID(az account show --query id -o tsv). None of these are secret credentials by themselves (that's the whole point of OIDC) — they're just identifiers the workflow needs to know which identity/tenant/subscription to authenticate as.Write the workflow file. Create
.github/workflows/deploy-aks.ymlin your app's repo:name: Build and Deploy to AKS on: push: branches: [main] permissions: id-token: write # required for OIDC contents: read env: ACR_NAME: acraskslearn ACR_LOGIN_SERVER: acraskslearn.azurecr.io IMAGE_NAME: demo-app RESOURCE_GROUP: rg-aks-learn CLUSTER_NAME: aks-learn DEPLOYMENT_NAME: demo-app CONTAINER_NAME: demo-app jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Azure login (OIDC) 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 ACR run: az acr login --name ${{ env.ACR_NAME }} - name: Build and push image run: | docker build -t ${{ env.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} . docker push ${{ env.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - name: Get AKS credentials uses: azure/aks-set-context@v4 with: resource-group: ${{ env.RESOURCE_GROUP }} cluster-name: ${{ env.CLUSTER_NAME }} - name: Deploy new image run: | kubectl set image deployment/${{ env.DEPLOYMENT_NAME }} \ ${{ env.CONTAINER_NAME }}=${{ env.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - name: Wait for rollout run: kubectl rollout status deployment/${{ env.DEPLOYMENT_NAME }} --timeout=180sCommit and push it to
main(make sure a Deployment nameddemo-appwith a container nameddemo-appalready exists on your cluster from an earlier module, or adjust the names to match).Watch it run and verify the rollout. In GitHub, open the Actions tab and watch the workflow run. Verify each step goes green, particularly the final
rollout statusstep. Cross-check from your own terminal:kubectl get deployment demo-app -o jsonpath='{.spec.template.spec.containers[0].image}'should show the new:<commit-sha>tag, andaz acr repository show-tags --name acraskslearn --repository demo-app --output tableshould list that SHA as a tag.Make a real code change and confirm end-to-end automation. Change something visible in your app (a string, a response body), commit, and push to
maindirectly (or merge a PR into it). Verify: without you running anydocker/az/kubectlcommand yourself, the new behavior is live —curlyour app's external endpoint (from module- and confirm the change appears.
Diagnose and fix: rollout step fails / times out. Deliberately push a change that builds fine but crashes at runtime (e.g. an uncaught exception on startup). Watch the workflow — the
kubectl rollout status --timeout=180sstep should fail (non-zero exit) rather than the workflow reporting false success. Investigate withkubectl rollout status deployment/demo-appandkubectl describe pod <new-pod-name>locally — confirm it showsCrashLoopBackOff. Fix the code, push again tomain, and verify the next run's rollout step succeeds and the bad ReplicaSet is fully replaced (kubectl get replicaset -l app=demo-appshows the old one scaled to 0).
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: Stand up a fully hands-off deploy pipeline and then prove it protects you from a bad release. Wire a GitHub Actions workflow that, on a push to main, authenticates to Azure without storing any long-lived secret, builds your app image, pushes it to your ACR under a tag that guarantees a real rollout, and updates the running Deployment — reusing the ACR and cluster you set up in modules 03 and 01 (conceptually building on module 03's push-vs-pull distinction, since the pipeline's identity needs a different grant than the cluster's own pull identity). First prove the happy path end to end: make a visible code change, push, and confirm the new behavior is live at your app's public endpoint without you running a single docker, az, or kubectl command by hand. Then deliberately ship a build that compiles but crashes on startup, and confirm the pipeline fails the run rather than reporting a false success — reason about which single workflow step is what makes a crash-looping deploy show up as red. Keep the trigger scoped to main only so you're not burning CI minutes and piling up ACR image tags on every branch, and remember the cluster and ACR keep billing between runs.
Stuck? One hint
Two things make the difference: tagging the image by immutable commit SHA (so kubectl set image actually changes the pod spec and triggers a rollout) and following the deploy with kubectl rollout status --timeout=... (so a crash-loop produces a non-zero exit and fails the workflow instead of passing silently). The secretless auth is OIDC federation, which also needs permissions: id-token: write.
Common mistakes & troubleshooting
- Tagging every build
latest. As explained above, this silently breakskubectl set image-based rollouts — the image reference string never changes, so no rolling update is triggered even though a new image was pushed. Always tag with something unique per build (commit SHA is simplest and most traceable). - Using a service-principal secret when OIDC was available. If you
set up
AZURE_CREDENTIALSas a long-lived JSON secret instead of OIDC, you've created a standing credential in GitHub that needs manual rotation and is a bigger blast radius if leaked. Prefer OIDC federation for anything new. - Forgetting
permissions: id-token: writein the workflow. Without it,azure/login@v2with OIDC fails to obtain a token — this is a common first-time OIDC setup mistake and the error message (something like "unable to get ACTIONS_ID_TOKEN_REQUEST_URL") doesn't always make the missing permission obvious at first glance. - Scoping the federated credential's
subjecttoo broadly or incorrectly. A typo'dsubject(wrong org/repo/branch pattern) means Azure AD will reject the token exchange even though everything else is configured correctly — double check it matchesrepo:<org>/<repo>:ref:refs/heads/<branch>exactly. - Deploying without a rollout-status check. A workflow that just runs
kubectl set imageand stops "succeeds" even if the new pods immediately crash-loop — always follow withkubectl rollout status --timeout=...so a bad deploy fails the workflow, not just fails silently in production. - Cost pitfall: CI building and pushing images on every commit to every
branch, not just
main. If theon:trigger is too broad (e.g. all branches, all PRs) instead of scoped topush: branches: [main], you accumulate ACR storage (many image layers/tags never cleaned up) and burn CI minutes for builds that were never meant to deploy anywhere — keep the trigger scoped, and consider an ACR retention/cleanup policy for old tags.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What's the core security advantage of OIDC federation over a stored service-principal secret for GitHub Actions?
- What GitHub Actions workflow permission is required for OIDC login to work, and what happens if it's missing?
- Why must each build be tagged with something other than
latestforkubectl set imageto actually trigger a rollout? - What two Azure roles does the CI identity need, and why are they different from the roles the AKS cluster itself needs (module 03)?
- Why is a
kubectl rollout statusstep necessary even afterkubectl set imagesucceeds? - What's a realistic cost consequence of triggering the build/push job
on every branch and PR instead of only on
pushtomain?
Show answers
- OIDC federation requires no long-lived secret to be stored in GitHub at all — Azure AD trusts short-lived tokens issued by GitHub's own OIDC provider for a specifically scoped repo/branch, so there's nothing durable to leak or need to rotate, unlike a stored service-principal password/certificate.
permissions: id-token: writeat the workflow or job level. Without it, the runner can't request an OIDC token from GitHub, andazure/login@v2fails to authenticate.- Kubernetes only triggers a rolling update when the Deployment's pod
template actually changes (e.g. a different image reference). If
every push reuses the tag
latest, the stored spec never changes text, so no rollout happens even though a new image was pushed to the registry. - The CI identity needs
AcrPush(to push new images to the registry) and a role letting it act against the AKS cluster (e.g. "Azure Kubernetes Service Cluster User Role" or broaderContributor). This is different from the cluster's own managed identity, which only needsAcrPull(module 03) to run images already in the registry — pushing and pulling are different operations needing different privilege. - Because
kubectl set imageonly submits the change and returns immediately — it doesn't wait to see whether the new pods actually become healthy. Withoutrollout status, a deploy that crash-loops would still report the workflow step as "succeeded." - It generates and pushes container images (consuming ACR storage as image layers/tags) and consumes CI runner minutes for commits/branches that were never intended to be deployed anywhere, accumulating cost with no corresponding benefit.
Next
09-capstone-project — put everything from this entire track together in one real, end-to-end AKS project.