DEV Community

Desmond Goldsmith
Desmond Goldsmith

Posted on Edited on

Migrating CI/CD from Azure DevOps to GitHub Actions with Azure OIDC, ACR, Helm and AKS

Introduction

As part of learning and preparing for a migration from Azure DevOps pipelines to GitHub Actions, I decided to build a small practice project before working with a real application.

The project started very simply. I wanted to get the application building, run tests, create a Docker image, authenticate GitHub Actions to Azure without storing a client secret, and push the image into Azure Container Registry.

After I got that working, I continued with the Kubernetes side and deployed the application to Azure Kubernetes Service using Helm.

Project repository: https://github.com/Desmondgoldsmith/REST-go-k8s-example
github url

What made the exercise useful for me was that things did not always work on the first attempt.

I had to figure out problems such as:

  • How GitHub Actions can authenticate to Azure using OIDC
  • Which Azure RBAC permissions the GitHub identity actually needs
  • How AKS authenticates GitHub Actions
  • Why kubelogin was required
  • Why a Kubernetes LoadBalancer service was stuck on <pending>
  • How an Azure public IP quota problem was affecting the Kubernetes service
  • How to make the Docker image name come from the GitHub repository instead of hard-coding it
  • How to use the GitHub Actions run number as the Docker image tag

So this article is not meant to be a perfect "copy these commands and everything will work" tutorial.

It is more of a record of how I built the pipeline, what I configured, what failed, how I investigated the failures, and what I learned from the process.


What I wanted to build

The end goal was roughly:

GitHub Pull Request
        ↓
GitHub Actions
        ↓
Go Build + Test
        ↓
Azure OIDC Authentication
        ↓
Azure Container Registry
        ↓
Docker Image
        ↓
Helm
        ↓
Azure Kubernetes Service
        ↓
Running Application
Enter fullscreen mode Exit fullscreen mode

The project started with CI and pushing the Docker image to ACR.

I then extended it to CD by deploying that image to AKS using Helm.


1. The Practice Application

I used a small Go REST API for the exercise.

The application itself was not the main focus. I just needed something simple enough that I could concentrate on the CI/CD process.

The application needed to:

  • Build successfully with Go
  • Pass tests
  • Run inside a Docker container
  • Expose an HTTP endpoint
  • Provide a health-check endpoint

The health-check endpoint is:

/health-check
Enter fullscreen mode Exit fullscreen mode

I first ran the application locally:

go run .
Enter fullscreen mode Exit fullscreen mode

Then I tested it:

curl http://localhost:10000/health-check
Enter fullscreen mode Exit fullscreen mode

The application returned:

{"healthy":true}
Enter fullscreen mode Exit fullscreen mode

That gave me confidence that the application itself was working before I started adding GitHub Actions and Azure.


Testing the Docker container locally

Before involving Azure, I also wanted to make sure the Dockerfile worked.

I built the image:

docker build -t go-rest-api:1.0 .
Enter fullscreen mode Exit fullscreen mode

Then I ran it:

docker run --rm -p 10000:10000 go-rest-api:1.0
Enter fullscreen mode Exit fullscreen mode

And tested it again:

curl http://localhost:10000/health-check
Enter fullscreen mode Exit fullscreen mode

The response was:

{"healthy":true}
Enter fullscreen mode Exit fullscreen mode

At this point I had two things working locally:

Go application
      ↓
Docker image
      ↓
Running container
      ↓
HTTP health check
Enter fullscreen mode Exit fullscreen mode

That was important because if something later failed in GitHub Actions, I knew the problem was probably in the pipeline or cloud configuration rather than the application itself.

2. Connecting the Project to GitHub

The source code was stored in my GitHub repository:

Desmondgoldsmith/REST-go-k8s-example
Enter fullscreen mode Exit fullscreen mode

I created a separate branch for testing:

git checkout -b test-branch
Enter fullscreen mode Exit fullscreen mode

Then pushed it:

git push -u origin test-branch
Enter fullscreen mode Exit fullscreen mode

I opened a Pull Request from:

test-branch → main
Enter fullscreen mode Exit fullscreen mode

One of the requirements I was working toward was having the pipeline run from Pull Requests rather than simply running after every normal push.

So the workflow eventually used:

on:
  pull_request:
Enter fullscreen mode Exit fullscreen mode

This means GitHub Actions can run checks when a Pull Request is opened or updated.

That gives me a useful CI/CD flow:

Developer changes code
        ↓
Push branch
        ↓
Create/update Pull Request
        ↓
GitHub Actions runs
        ↓
Build / Test / Deploy
Enter fullscreen mode Exit fullscreen mode

3. Preparing Azure

For the Azure side of the project, I used an Azure for Students subscription.

I created the resources needed for the lab in:

South Africa North
Enter fullscreen mode Exit fullscreen mode

The main resources were:

Resource Group:
devops-aks-lab-rg-sa

Azure Container Registry:
dessydevopsacr

AKS Cluster:
rest-go-aks
Enter fullscreen mode Exit fullscreen mode

The Azure Container Registry login server was:

dessydevopsacr.azurecr.io
Enter fullscreen mode Exit fullscreen mode

The purpose of ACR was to act as the place where GitHub Actions would push the Docker image.

AKS would later pull that image and run it.

So the basic relationship became:

GitHub Actions
      ↓
Docker Image
      ↓
Azure Container Registry
      ↓
AKS
      ↓
Kubernetes Pod
Enter fullscreen mode Exit fullscreen mode

4. Why I Used Azure OIDC

One of the things I wanted to understand properly was authentication.

A simple approach would have been to create an Azure service principal with a client secret and store that secret inside GitHub.

That would look something like:

GitHub Actions
      ↓
Client ID + Client Secret
      ↓
Azure
Enter fullscreen mode Exit fullscreen mode

But that means having a long-lived secret that has to be stored and managed.

Instead, I used OpenID Connect (OIDC).

The authentication flow became:

GitHub Actions
      ↓
OIDC Token
      ↓
Microsoft Entra ID
      ↓
Azure
Enter fullscreen mode Exit fullscreen mode

The important thing for me was that there was no Azure client secret being stored in GitHub.

The GitHub Actions workflow receives an OIDC identity token, and Azure checks whether that token matches the federated credential that I configured.


5. Creating the Azure App Registration

In the Azure Portal, I went to:

Microsoft Entra ID
    ↓
App registrations
    ↓
New registration
Enter fullscreen mode Exit fullscreen mode

I created an application called:

github-actions-rest-go-acr
Enter fullscreen mode Exit fullscreen mode

I used:

Accounts in this organizational directory only
Enter fullscreen mode Exit fullscreen mode

After creating the application, Azure provided several identifiers.

The important ones for the workflow were:

Application (client) ID
Directory (tenant) ID
Azure subscription ID
Enter fullscreen mode Exit fullscreen mode

There is also an important distinction between the Application/Client ID, the Application Object ID, and the Service Principal Object ID.

I initially had to understand this distinction because Azure role assignments are associated with the service principal identity.

_Azure Portal → Microsoft Entra ID → App registrations → github-actions-rest-go-acr → Overview

_

6. Creating the Federated Credential

Creating the App Registration was not enough.

I also needed to tell Azure:

"I trust this specific GitHub Actions identity."

Inside the App Registration, I went to:

Certificates & secrets
    ↓
Federated credentials
    ↓
Add credential
Enter fullscreen mode Exit fullscreen mode

I selected:

GitHub Actions deploying Azure resources
Enter fullscreen mode Exit fullscreen mode

Then I configured the GitHub repository information.

For this project:

GitHub owner:
Desmondgoldsmith

Repository:
REST-go-k8s-example
Enter fullscreen mode Exit fullscreen mode

I selected:

Entity type:
Pull request
Enter fullscreen mode Exit fullscreen mode

That choice was important because the workflow was configured to run on:

on:
  pull_request:
Enter fullscreen mode Exit fullscreen mode

I named the federated credential:

github-actions-rest-go-acr-pr
Enter fullscreen mode Exit fullscreen mode

The audience was:

api://AzureADTokenExchange
Enter fullscreen mode Exit fullscreen mode

Conceptually, this created a trust relationship between:

GitHub repository
       ↓
GitHub Actions
       ↓
OIDC identity
       ↓
Microsoft Entra ID
       ↓
Azure App Registration
Enter fullscreen mode Exit fullscreen mode

The Federated credential configuration in Azure.

7. Understanding What the Federated Credential Actually Does

This was one of the most important concepts I learned during the project.

The federated credential does not simply mean:

"GitHub is allowed to do anything in Azure."

It establishes a trust relationship.

When the workflow runs, GitHub can provide an OIDC token.

Azure receives that token and checks whether it matches the federated credential.

Conceptually:

GitHub Actions
       ↓
OIDC token
       ↓
Microsoft Entra ID
       ↓
"Does this identity match my trust configuration?"
       ↓
Yes
       ↓
Application identity authenticated
Enter fullscreen mode Exit fullscreen mode

But authentication is only one part of the process.

Even after Azure knows who the identity is, Azure still needs to know what that identity is allowed to do.

That is where Azure RBAC comes in.

So I think of it as:

Federated Credential
        ↓
WHO are you?

Azure RBAC
        ↓
WHAT are you allowed to do?
Enter fullscreen mode Exit fullscreen mode

8. Giving GitHub Actions Permission to Push to ACR

Next, I needed to give the GitHub Actions identity permission to push images into ACR.

I opened:

Azure Portal
    ↓
Container Registries
    ↓
dessydevopsacr
    ↓
Access control (IAM)
Enter fullscreen mode Exit fullscreen mode

Then:

Add
    ↓
Add role assignment
Enter fullscreen mode Exit fullscreen mode

For the role, I selected:

AcrPush
Enter fullscreen mode Exit fullscreen mode

The important part here was that I did not give the GitHub identity broad permissions such as Contributor.

The pipeline only needed permission to work with container images in the registry.

So the assignment was essentially:

github-actions-rest-go-acr
          ↓
       AcrPush
          ↓
  dessydevopsacr
Enter fullscreen mode Exit fullscreen mode

This follows the principle of least privilege.

Azure Portal → ACR → Access control (IAM) → Role assignments showing the AcrPush assignment.


9. Adding the Azure Values to GitHub

I then went to the GitHub repository:

Settings
    ↓
Secrets and variables
    ↓
Actions
    ↓
Secrets
Enter fullscreen mode Exit fullscreen mode

I created:

AZURE_CLIENT_ID
AZURE_TENANT_ID
AZURE_SUBSCRIPTION_ID
Enter fullscreen mode Exit fullscreen mode

The values were:

AZURE_CLIENT_ID       = <AZURE_CLIENT_ID>
AZURE_TENANT_ID      = <AZURE_TENANT_ID>
AZURE_SUBSCRIPTION_ID = <AZURE_SUBSCRIPTION_ID>
Enter fullscreen mode Exit fullscreen mode

Also notice what is missing:

AZURE_CLIENT_SECRET
Enter fullscreen mode Exit fullscreen mode

There was no client secret.

That is because the authentication was based on OIDC and the federated credential.

GitHub → Settings → Secrets and variables → Actions


10. Allowing GitHub Actions to Request an OIDC Token

The workflow also needed permission to request an OIDC token.

I added:

permissions:
  id-token: write
  contents: read
Enter fullscreen mode Exit fullscreen mode

The important permission here is:

id-token: write
Enter fullscreen mode Exit fullscreen mode

This allows the workflow to request an OIDC identity token.

The:

contents: read
Enter fullscreen mode Exit fullscreen mode

permission allows the workflow to read the repository contents.

So the beginning of the workflow became:

name: Go CI/CD

on:
  pull_request:

permissions:
  id-token: write
  contents: read
Enter fullscreen mode Exit fullscreen mode

This was another piece of the authentication chain.


11. Logging Into Azure from GitHub Actions

Once the Azure App Registration, federated credential, GitHub secrets, and permissions were configured, I added the Azure login action:

- name: Azure login
  uses: azure/login@v2
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Enter fullscreen mode Exit fullscreen mode

This is where the configuration started coming together.

The workflow:

Requests OIDC token
       ↓
Azure validates token
       ↓
Federated credential matches
       ↓
GitHub Actions is authenticated
       ↓
Azure CLI can be used
Enter fullscreen mode Exit fullscreen mode

12. Testing Azure Authentication

I pushed the workflow changes to my test branch and opened a Pull Request.

GitHub Actions ran the workflow.

This time the Azure login step completed successfully.

That was an important checkpoint.

It proved that this part of the setup was working:

GitHub Actions
       ↓
OIDC
       ↓
Microsoft Entra ID
       ↓
Federated Credential
       ↓
Azure Application Identity
Enter fullscreen mode Exit fullscreen mode

GitHub Actions run showing the successful Azure login


13. Logging Docker Into ACR

Being authenticated to Azure does not automatically mean Docker is logged into the container registry.

So I added:

- name: Log in to Azure Container Registry
  run: az acr login --name dessydevopsacr
Enter fullscreen mode Exit fullscreen mode

This uses the Azure identity that was established by azure/login.

The registry is:

dessydevopsacr.azurecr.io
Enter fullscreen mode Exit fullscreen mode

After this step, Docker can authenticate against the ACR registry.

The flow is now:

GitHub Actions
      ↓
Azure OIDC Login
      ↓
Azure CLI
      ↓
ACR Login
      ↓
Docker
Enter fullscreen mode Exit fullscreen mode

14. Moving From CI to CD

At this point I had the CI side working.

The next step was to actually deploy the Docker image to Kubernetes.

The target platform was:

Azure Kubernetes Service (AKS)
Enter fullscreen mode Exit fullscreen mode

My AKS cluster was:

rest-go-aks
Enter fullscreen mode Exit fullscreen mode

I also created a Kubernetes namespace for the application:

go-rest-api
Enter fullscreen mode Exit fullscreen mode

The deployment flow would now become:

Pull Request
      ↓
GitHub Actions
      ↓
Go Build
      ↓
Go Test
      ↓
Docker Build
      ↓
Push Image to ACR
      ↓
Helm
      ↓
AKS
      ↓
Pod
Enter fullscreen mode Exit fullscreen mode

15. Why I Used Helm

Instead of putting the Kubernetes Deployment and Service YAML directly into the GitHub Actions workflow, I used Helm.

My Helm chart looked roughly like this:

helm/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── _helpers.tpl
    ├── deployment.yaml
    └── service.yaml
Enter fullscreen mode Exit fullscreen mode

The chart contains the Kubernetes resources needed by the application.

The important idea is that the chart provides the structure, while values such as the image repository and image tag can be supplied when the deployment runs.

For example:

image:
  repository: dessydevopsacr.azurecr.io/go-rest-api
  pullPolicy: IfNotPresent
  tag: "latest"
Enter fullscreen mode Exit fullscreen mode

The latest value here is simply the default in the values file.

During the actual deployment, GitHub Actions overrides it with the image we just built.


16. The Kubernetes Deployment

The Helm Deployment template contains the container definition.

The important part is:

containers:
  - name: {{ .Chart.Name }}
    image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
Enter fullscreen mode Exit fullscreen mode

This means Helm takes:

image.repository
Enter fullscreen mode Exit fullscreen mode

and:

image.tag
Enter fullscreen mode Exit fullscreen mode

and combines them into:

registry/repository:tag
Enter fullscreen mode Exit fullscreen mode

For example:

dessydevopsacr.azurecr.io/rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

The Deployment also has readiness and liveness probes:

readinessProbe:
  httpGet:
    path: /health-check
    port: http
  initialDelaySeconds: 5
  periodSeconds: 10

livenessProbe:
  httpGet:
    path: /health-check
    port: http
  initialDelaySeconds: 10
  periodSeconds: 20
Enter fullscreen mode Exit fullscreen mode

This allows Kubernetes to use the application's health endpoint when determining whether the container is ready and whether it is still healthy.


17. The Kubernetes Service

I also created a Kubernetes Service.

For the lab, I used:

service:
  type: LoadBalancer
  port: 10000
  targetPort: 10000
Enter fullscreen mode Exit fullscreen mode

The Service exposes the application outside the Kubernetes cluster.

The basic flow is:

Internet
   ↓
Azure Load Balancer
   ↓
Kubernetes Service
   ↓
Pod
   ↓
Go Application
Enter fullscreen mode Exit fullscreen mode

18. Connecting GitHub Actions to AKS

The GitHub Actions identity also needed permission to interact with the AKS cluster.

The AKS cluster was configured to use Microsoft Entra ID and Azure RBAC for Kubernetes authorization.

I assigned the GitHub identity the permissions required to access the cluster and perform the deployment.

The important distinction here is that ACR access and AKS access are separate permissions.

For example:

GitHub Actions Identity
       │
       ├──────────────→ ACR
       │                  │
       │                AcrPush
       │
       └──────────────→ AKS
                          │
                   Cluster User Role
                          +
                    RBAC Writer
Enter fullscreen mode Exit fullscreen mode

This was important because having AcrPush does not automatically give the identity permission to deploy to Kubernetes.


19. Getting the AKS Context

I used Microsoft's AKS context action:

- name: Set AKS context
  uses: azure/aks-set-context@v5
  with:
    resource-group: devops-aks-lab-rg-sa
    cluster-name: rest-go-aks
    admin: 'false'
    use-kubelogin: 'true'
Enter fullscreen mode Exit fullscreen mode

I deliberately used:

admin: 'false'
Enter fullscreen mode Exit fullscreen mode

because I wanted the workflow to authenticate using the configured Azure identity and Kubernetes RBAC rather than using the AKS administrator credentials.


20. The kubelogin Problem

This was one of the first real problems I encountered when connecting GitHub Actions to AKS.

The workflow initially failed with an error saying that kubelogin could not be found.

The important part of the error was:

Unable to locate executable file: kubelogin
Enter fullscreen mode Exit fullscreen mode

The AKS credentials were being retrieved, but the GitHub runner did not have the required kubelogin executable available.

Instead of assuming the AKS configuration was broken, I looked at the error carefully.

It was telling me that the authentication mechanism needed kubelogin.

So I added:

- name: Install kubelogin
  uses: azure/use-kubelogin@v1.2
  with:
    kubelogin-version: 'v0.0.24'
Enter fullscreen mode Exit fullscreen mode

I pinned the version rather than relying on an automatically resolved latest version.

The AKS context step then used:

use-kubelogin: 'true'
Enter fullscreen mode Exit fullscreen mode

After this change, the workflow was able to configure the AKS context successfully.

The failed GitHub Actions run containing the kubelogin error

The later successful GitHub Actions run with the Install kubelogin and Set AKS context steps passing

21. Checking the AKS Context Locally

While troubleshooting the Kubernetes side, I also used kubectl locally to understand what was happening.

To see available contexts:

kubectl config get-contexts
Enter fullscreen mode Exit fullscreen mode

My AKS context was:

rest-go-aks
Enter fullscreen mode Exit fullscreen mode

I could switch to it with:

kubectl config use-context rest-go-aks
Enter fullscreen mode Exit fullscreen mode

Then verify the cluster:

kubectl get nodes
Enter fullscreen mode Exit fullscreen mode

This was useful because it gave me a way to distinguish between:

Kubernetes problem
Enter fullscreen mode Exit fullscreen mode

and:

GitHub Actions authentication problem
Enter fullscreen mode Exit fullscreen mode

22. Deploying With Helm

Once the GitHub runner could authenticate to AKS, the deployment step was:

- name: Deploy to AKS with Helm
  run: |
    helm upgrade --install go-rest-api ./helm \
      --namespace go-rest-api \
      --set image.repository=dessydevopsacr.azurecr.io/${{ env.IMAGE_NAME }} \
      --set image.tag=${{ github.run_number }}
Enter fullscreen mode Exit fullscreen mode

There are a few things happening here.

First:

helm upgrade --install
Enter fullscreen mode Exit fullscreen mode

means:

If the release exists, upgrade it. If it does not exist, install it.

The release name is:

go-rest-api
Enter fullscreen mode Exit fullscreen mode

The namespace is:

go-rest-api
Enter fullscreen mode Exit fullscreen mode

The image repository and tag are supplied dynamically.


23. Why I Changed From Commit SHA to Run Number

Originally, I used the GitHub commit SHA as the Docker image tag.

That looked like:

go-rest-api:<commit-sha>
Enter fullscreen mode Exit fullscreen mode

Using the SHA has an advantage because the image can be traced directly back to a commit.

However, my supervisor asked me to change this.

The requirement was to use the pipeline build/run number instead because it is easier to follow as a simple linear sequence.

So instead of:

${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

I changed the workflow to:

${{ github.run_number }}
Enter fullscreen mode Exit fullscreen mode

Now the image tags look like:

:10
:11
:12
Enter fullscreen mode Exit fullscreen mode

For example:

dessydevopsacr.azurecr.io/rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

This makes it much easier to say:

"Deployment 12 is running image 12."

The important part is that the same run number is used when building, pushing, and deploying the image.


24. Making the Docker Image Name Dynamic

Another requirement from my supervisor was that the Docker image name should come from the GitHub repository.

I did not want to hard-code:

go-rest-api
Enter fullscreen mode Exit fullscreen mode

as the image name.

The GitHub context provides:

github.event.repository.name
Enter fullscreen mode Exit fullscreen mode

which returns the repository name.

For this repository:

REST-go-k8s-example
Enter fullscreen mode Exit fullscreen mode

Docker image repository names need to be lowercase, so I added:

- name: Set image name
  run: echo "IMAGE_NAME=$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
Enter fullscreen mode Exit fullscreen mode

This creates:

IMAGE_NAME=rest-go-k8s-example
Enter fullscreen mode Exit fullscreen mode

The image can then be built using:

- name: Build Docker image
  run: docker build -t dessydevopsacr.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.run_number }} .
Enter fullscreen mode Exit fullscreen mode

And pushed using:

- name: Push Docker image
  run: docker push dessydevopsacr.io/${{ env.IMAGE_NAME }}:${{ github.run_number }}
Enter fullscreen mode Exit fullscreen mode

The actual registry should of course be:

dessydevopsacr.azurecr.io
Enter fullscreen mode Exit fullscreen mode

So the final image becomes:

dessydevopsacr.azurecr.io/rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

This is better than hard-coding the application name because the workflow can be reused for another repository without changing the Docker image name logic.


25. The Final CI/CD Workflow

After putting the pieces together, my workflow became:

name: Go CI/CD

on:
  pull_request:

permissions:
  id-token: write
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.27'

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

      - name: Install kubelogin
        uses: azure/use-kubelogin@v1.2
        with:
          kubelogin-version: 'v0.0.24'

      - name: Set AKS context
        uses: azure/aks-set-context@v5
        with:
          resource-group: devops-aks-lab-rg-sa
          cluster-name: rest-go-aks
          admin: 'false'
          use-kubelogin: 'true'

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

      - name: Download dependencies
        run: go mod download

      - name: Build Go application
        run: go build -v ./...

      - name: Run Go tests
        run: go test ./...

      - name: Set image name
        run: echo "IMAGE_NAME=$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"

      - name: Build Docker image
        run: docker build -t dessydevopsacr.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.run_number }} .

      - name: Push Docker image
        run: docker push dessydevopsacr.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.run_number }}

      - name: Deploy to AKS with Helm
        run: |
          helm upgrade --install go-rest-api ./helm \
            --namespace go-rest-api \
            --set image.repository=dessydevopsacr.azurecr.io/${{ env.IMAGE_NAME }} \
            --set image.tag=${{ github.run_number }}
Enter fullscreen mode Exit fullscreen mode

The important thing about this workflow is not memorizing every line.

The workflow is basically doing this:

1. Checkout source code
        ↓
2. Install Go
        ↓
3. Authenticate to Azure
        ↓
4. Install kubelogin
        ↓
5. Connect to AKS
        ↓
6. Login to ACR
        ↓
7. Download Go dependencies
        ↓
8. Build Go application
        ↓
9. Run tests
        ↓
10. Determine image name from GitHub repository
        ↓
11. Build Docker image
        ↓
12. Push image to ACR
        ↓
13. Deploy image to AKS with Helm
Enter fullscreen mode Exit fullscreen mode

26. Verifying the Image in ACR

After a successful workflow run, I checked Azure Container Registry.

The repository name was now based on the GitHub repository:

rest-go-k8s-example
Enter fullscreen mode Exit fullscreen mode

The image tag was based on the GitHub Actions run number.

For example:

rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

The complete image reference was:

dessydevopsacr.azurecr.io/rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

This confirmed that the Docker build and push stages were working.

Azure Portal → Container Registry → Repositories → rest-go-k8s-example

27. Verifying the Helm Release

After deployment, I checked the Helm release:

helm list -n go-rest-api
Enter fullscreen mode Exit fullscreen mode

The release showed as deployed:

NAME          NAMESPACE     REVISION    STATUS
go-rest-api   go-rest-api   3           deployed
Enter fullscreen mode Exit fullscreen mode

The important thing here is that Helm was managing the Kubernetes deployment rather than GitHub Actions manually applying individual Kubernetes YAML files.

To see releases across all namespaces, I could also use:

helm list -A
Enter fullscreen mode Exit fullscreen mode

And if I wanted to see the Kubernetes resources:

kubectl get pods -n go-rest-api
Enter fullscreen mode Exit fullscreen mode
kubectl get svc -n go-rest-api
Enter fullscreen mode Exit fullscreen mode
kubectl get deployments -n go-rest-api
Enter fullscreen mode Exit fullscreen mode

28. Checking What Image AKS Is Running

One of the checks I found useful was looking directly at the Deployment.

For example:

kubectl get deployment go-rest-api \
  -n go-rest-api \
  -o jsonpath='{.spec.template.spec.containers[0].image}'
Enter fullscreen mode Exit fullscreen mode

This lets me verify the exact image Kubernetes is configured to run.

The result was:

dessydevopsacr.azurecr.io/rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

This was a useful confirmation because it connected the different parts of the pipeline:

GitHub Actions Run 12
        ↓
Docker Image Tag 12
        ↓
ACR Image :12
        ↓
Helm
        ↓
AKS Deployment
        ↓
Image :12
Enter fullscreen mode Exit fullscreen mode

29. The LoadBalancer Problem

One of the more interesting problems happened when I created the Kubernetes Service.

The Service was:

type: LoadBalancer
Enter fullscreen mode Exit fullscreen mode

but when I checked it:

kubectl get svc -n go-rest-api
Enter fullscreen mode Exit fullscreen mode

the external IP was:

<pending>
Enter fullscreen mode Exit fullscreen mode

At first, this could have been a Kubernetes configuration problem.

So rather than guessing, I described the Service:

kubectl describe svc go-rest-api -n go-rest-api
Enter fullscreen mode Exit fullscreen mode

The Events section showed:

Warning CreateOrUpdatePublicIPAddress
ERROR CODE: PublicIPCountLimitReached
Cannot create more than 3 public IP addresses for this subscription in this region.
Enter fullscreen mode Exit fullscreen mode

That immediately changed the direction of the investigation.

The Kubernetes Service itself was not the main problem.

Azure was refusing to create another public IP because I had reached the public IP quota for the subscription and region.


30. Finding the Resource Consuming the Public IP

I checked the public IP resources in Azure.

There were multiple public IP resources associated with the Kubernetes environment.

I also discovered that I had an older Helm release in the default namespace:

go-rest-api
Enter fullscreen mode Exit fullscreen mode

while I was now using:

go-rest-api
Enter fullscreen mode Exit fullscreen mode

in the intended namespace:

go-rest-api
Enter fullscreen mode Exit fullscreen mode

I removed the old Helm release:

helm uninstall go-rest-api -n default
Enter fullscreen mode Exit fullscreen mode

I then investigated the remaining Azure public IP resources.

This was a good reminder that deleting a Kubernetes object and checking Azure resources are sometimes two separate parts of troubleshooting.

Kubernetes may request Azure resources such as:

Public IP
Load Balancer
Network Interface
Enter fullscreen mode Exit fullscreen mode

and when troubleshooting, it is useful to look at both sides.


31. The Service Finally Received an External IP

After cleaning up the unused resources and allowing Azure to create the required public IP, the Service received an external IP.

I checked it again:

kubectl get svc -n go-rest-api
Enter fullscreen mode Exit fullscreen mode

The Service was now showing an external IP instead of:

<pending>
Enter fullscreen mode Exit fullscreen mode

I then tested the application from outside the cluster:

curl http://<EXTERNAL-IP>:10000/health-check
Enter fullscreen mode Exit fullscreen mode

The application returned:

{"healthy":true}
Enter fullscreen mode Exit fullscreen mode

That was the final confirmation that the complete deployment path was working.

The successful external curl request returning {"healthy":true}


32. What the Complete Architecture Looks Like

At this point the project had moved beyond simply pushing an image to ACR.

The complete flow was:

Developer
    ↓
GitHub Pull Request
    ↓
GitHub Actions
    ↓
Go Build
    ↓
Go Tests
    ↓
GitHub OIDC
    ↓
Microsoft Entra ID
    ↓
Azure RBAC
    ↓
Azure Container Registry
    ↓
Docker Image
    ↓
Helm
    ↓
AKS
    ↓
Kubernetes Deployment
    ↓
Kubernetes Service
    ↓
Azure Load Balancer
    ↓
Go REST API
Enter fullscreen mode Exit fullscreen mode

There are actually two important Azure authentication/authorization paths involved.

For pushing the image:

GitHub Actions
      ↓
OIDC
      ↓
Microsoft Entra ID
      ↓
GitHub Actions App
      ↓
AcrPush
      ↓
ACR
Enter fullscreen mode Exit fullscreen mode

For running the application:

AKS
      ↓
Kubelet Identity
      ↓
AcrPull
      ↓
ACR
      ↓
Docker Image
      ↓
Pod
Enter fullscreen mode Exit fullscreen mode

And for deploying to Kubernetes:

GitHub Actions
      ↓
OIDC
      ↓
Microsoft Entra ID
      ↓
GitHub Actions Identity
      ↓
AKS Cluster User Role
      +
Azure Kubernetes Service RBAC Writer
      ↓
Kubernetes API
      ↓
Helm Deployment
Enter fullscreen mode Exit fullscreen mode

Understanding these as separate permission paths helped me understand why one permission does not automatically give another.


33. ACR Push vs AKS Pull

This was another concept that became much clearer after actually doing the project.

There are two different actions:

GitHub Actions pushes the image

GitHub Actions
      ↓
AcrPush
      ↓
ACR
Enter fullscreen mode Exit fullscreen mode

GitHub Actions needs permission to push the image.

AKS pulls the image

AKS Node/Kubelet
      ↓
AcrPull
      ↓
ACR
Enter fullscreen mode Exit fullscreen mode

The AKS kubelet identity needs permission to pull the image.

So I did not need to create a Kubernetes imagePullSecret for this setup.

The Azure identities handled the registry authentication.


34. Checking Kubernetes Resources

During the troubleshooting process, I also learned that I don't need to memorize every resource name.

If I forget which namespaces exist:

kubectl get ns
Enter fullscreen mode Exit fullscreen mode

If I want to see Pods everywhere:

kubectl get pods -A
Enter fullscreen mode Exit fullscreen mode

Deployments:

kubectl get deployments -A
Enter fullscreen mode Exit fullscreen mode

Services:

kubectl get svc -A
Enter fullscreen mode Exit fullscreen mode

All common resources:

kubectl get all -A
Enter fullscreen mode Exit fullscreen mode

For Helm releases:

helm list -A
Enter fullscreen mode Exit fullscreen mode

The -n option means namespace.

For example:

helm list -n go-rest-api
Enter fullscreen mode Exit fullscreen mode

means:

Show Helm releases in the go-rest-api namespace.

And:

helm list -A
Enter fullscreen mode Exit fullscreen mode

means:

Show Helm releases across all namespaces.


35. Validating Helm Before Deploying

Another useful command during the Helm work was:

helm template test-release ./helm
Enter fullscreen mode Exit fullscreen mode

This renders the Helm templates locally without actually deploying them.

That helped catch template problems before sending the chart to AKS.

At one point, I encountered an error related to:

.Values.serviceAccount.create
Enter fullscreen mode Exit fullscreen mode

The chart still contained generated templates that expected values I was no longer using.

I simplified the chart and removed the unused generated ServiceAccount and test templates.

After that:

helm template test-release ./helm
Enter fullscreen mode Exit fullscreen mode

rendered successfully.

This reinforced an important lesson for me:

Helm templates are just templates until Helm renders them.

So if something is wrong with the chart, rendering it locally is often a much faster way to find the problem.


36. The Final Image Naming

The final image naming approach was one of the changes I made based on feedback from my supervisor.

Instead of:

go-rest-api:<commit-sha>
Enter fullscreen mode Exit fullscreen mode

the pipeline now creates:

<acr>/<github-repository-name>:<github-run-number>
Enter fullscreen mode Exit fullscreen mode

For this project:

dessydevopsacr.azurecr.io/rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

The important parts are:

dessydevopsacr.azurecr.io
        ↓
ACR registry

rest-go-k8s-example
        ↓
GitHub repository name

12
        ↓
GitHub Actions run number
Enter fullscreen mode Exit fullscreen mode

This means the workflow logic is reusable without hard-coding the Docker repository name.


37. What I Learned

The biggest thing I learned from this exercise was that CI/CD is not just about writing a YAML file.

The YAML is actually the easy part once you understand what each component needs to do.

For example, when the pipeline failed because of kubelogin, the important thing was not memorizing:

uses: azure/use-kubelogin@v1.2
Enter fullscreen mode Exit fullscreen mode

The important thing was understanding what the error meant.

The same thing happened with the LoadBalancer.

Instead of immediately changing Kubernetes configuration, I checked:

kubectl describe svc go-rest-api -n go-rest-api
Enter fullscreen mode Exit fullscreen mode

and found:

PublicIPCountLimitReached
Enter fullscreen mode Exit fullscreen mode

That told me the problem was actually an Azure quota issue.

So one of the biggest lessons for me was:

Don't guess when troubleshooting. Start with the error, identify which component is reporting it, and investigate that component.


38. Authentication vs Authorization

Another concept that became much clearer through the project was the difference between authentication and authorization.

Authentication

Authentication answers:

Who are you?

In this project:

GitHub Actions
      ↓
OIDC
      ↓
Microsoft Entra ID
      ↓
Federated Credential
Enter fullscreen mode Exit fullscreen mode

Authorization

Authorization answers:

What are you allowed to do?

For example:

GitHub Actions Identity
      ↓
AcrPush
      ↓
ACR
Enter fullscreen mode Exit fullscreen mode

and:

GitHub Actions Identity
      ↓
AKS RBAC permissions
      ↓
Kubernetes API
Enter fullscreen mode Exit fullscreen mode

This distinction is easy to read about, but it became much more obvious after configuring both sides myself.


39. Final Result

I started with a small Go REST API running locally.

Then I gradually built the following:

Go Application
      ↓
Docker
      ↓
GitHub Repository
      ↓
GitHub Pull Request
      ↓
GitHub Actions
      ↓
Go Build + Test
      ↓
OIDC Authentication
      ↓
Microsoft Entra ID
      ↓
Azure RBAC
      ↓
Azure Container Registry
      ↓
Docker Image
      ↓
Helm
      ↓
AKS
      ↓
Kubernetes Deployment
      ↓
Kubernetes Service
      ↓
External Load Balancer
      ↓
Go REST API
Enter fullscreen mode Exit fullscreen mode

The final Docker image looked like:

dessydevopsacr.azurecr.io/rest-go-k8s-example:12
Enter fullscreen mode Exit fullscreen mode

And Kubernetes was running that image through the Helm-managed deployment.

The application's health endpoint was reachable externally:

curl http://<EXTERNAL-IP>:10000/health-check
Enter fullscreen mode Exit fullscreen mode

and returned:

{"healthy":true}
Enter fullscreen mode Exit fullscreen mode

That was a good point to stop and look back at the whole process because I had gone from a local Go application to a working CI/CD pipeline and Kubernetes deployment.


40. What I Want to Explore Next

There is still another part of the project that I want to investigate.

In Jenkins, one common approach is using Shared Libraries.

The idea is that instead of every repository maintaining its own large pipeline, a central repository contains the reusable pipeline logic.

Then individual repositories can call that shared workflow with very little configuration.

The GitHub Actions equivalent I want to investigate is reusable workflows.

The goal would be something like:

Central GitHub Actions workflow
              ↓
       Reusable workflow
        ↙     ↓      ↘
     Repo A  Repo B  Repo C
Enter fullscreen mode Exit fullscreen mode

Instead of copying the complete CI/CD workflow into every repository, the workflow logic could live centrally.

Then if the central workflow is updated, the repositories using it can benefit from the change without duplicating the whole pipeline.

That is the next part I want to understand and implement properly.

I don't want to simply copy an example and call it done. I want to understand how the reusable workflow receives things such as:

Repository name
Azure credentials
AKS cluster
Resource group
ACR
Environment
Image tag
Enter fullscreen mode Exit fullscreen mode

and how the calling repository can keep its configuration minimal.


Conclusion

This project started as a small exercise to understand GitHub Actions, but it ended up teaching me much more about how the different pieces of a real CI/CD system fit together.

The main things I worked with were:

  • GitHub Pull Requests
  • GitHub Actions
  • Go builds and tests
  • Docker
  • Azure OIDC
  • Microsoft Entra ID
  • Azure RBAC
  • Azure Container Registry
  • AKS
  • Kubernetes RBAC
  • Helm
  • Kubernetes Services
  • Azure Load Balancers
  • Troubleshooting Azure and Kubernetes

More importantly, I learned that when something fails, I should not immediately start changing random configuration.

I can first ask:

What component failed?
        ↓
What exactly is the error saying?
        ↓
What resource is involved?
        ↓
What command can I use to inspect it?
        ↓
What does the result tell me?
        ↓
What is the smallest change needed?
Enter fullscreen mode Exit fullscreen mode

That is probably the most useful part of the exercise for me.

The project is now at the point where the application can go from a Pull Request through GitHub Actions, into ACR, and then into AKS using Helm.

The next step is to make the workflow more reusable so that the same CI/CD logic can be shared across multiple repositories without copying the entire workflow each time.

Top comments (0)