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
kubeloginwas required - Why a Kubernetes
LoadBalancerservice 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
For a public article, these should not be published as real values.
Use placeholders such as:
<AZURE_CLIENT_ID>
<AZURE_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
The actual values should never be published in an article.
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/[email protected]
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/[email protected]
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-apinamespace.
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/[email protected]
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.










