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
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
I first ran the application locally:
go run .
Then I tested it:
curl http://localhost:10000/health-check
The application returned:
{"healthy":true}
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 .
Then I ran it:
docker run --rm -p 10000:10000 go-rest-api:1.0
And tested it again:
curl http://localhost:10000/health-check
The response was:
{"healthy":true}
At this point I had two things working locally:
Go application
↓
Docker image
↓
Running container
↓
HTTP health check
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
I created a separate branch for testing:
git checkout -b test-branch
Then pushed it:
git push -u origin test-branch
I opened a Pull Request from:
test-branch → main
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:
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
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
The main resources were:
Resource Group:
devops-aks-lab-rg-sa
Azure Container Registry:
dessydevopsacr
AKS Cluster:
rest-go-aks
The Azure Container Registry login server was:
dessydevopsacr.azurecr.io
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
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
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
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
I created an application called:
github-actions-rest-go-acr
I used:
Accounts in this organizational directory only
After creating the application, Azure provided several identifiers.
The important ones for the workflow were:
Application (client) ID
Directory (tenant) ID
Azure subscription ID
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
I selected:
GitHub Actions deploying Azure resources
Then I configured the GitHub repository information.
For this project:
GitHub owner:
Desmondgoldsmith
Repository:
REST-go-k8s-example
I selected:
Entity type:
Pull request
That choice was important because the workflow was configured to run on:
on:
pull_request:
I named the federated credential:
github-actions-rest-go-acr-pr
The audience was:
api://AzureADTokenExchange
Conceptually, this created a trust relationship between:
GitHub repository
↓
GitHub Actions
↓
OIDC identity
↓
Microsoft Entra ID
↓
Azure App Registration
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
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?
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)
Then:
Add
↓
Add role assignment
For the role, I selected:
AcrPush
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
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
I created:
AZURE_CLIENT_ID
AZURE_TENANT_ID
AZURE_SUBSCRIPTION_ID
The values were:
AZURE_CLIENT_ID = <AZURE_CLIENT_ID>
AZURE_TENANT_ID = <AZURE_TENANT_ID>
AZURE_SUBSCRIPTION_ID = <AZURE_SUBSCRIPTION_ID>
Also notice what is missing:
AZURE_CLIENT_SECRET
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
The important permission here is:
id-token: write
This allows the workflow to request an OIDC identity token.
The:
contents: read
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
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 }}
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
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
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
This uses the Azure identity that was established by azure/login.
The registry is:
dessydevopsacr.azurecr.io
After this step, Docker can authenticate against the ACR registry.
The flow is now:
GitHub Actions
↓
Azure OIDC Login
↓
Azure CLI
↓
ACR Login
↓
Docker
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)
My AKS cluster was:
rest-go-aks
I also created a Kubernetes namespace for the application:
go-rest-api
The deployment flow would now become:
Pull Request
↓
GitHub Actions
↓
Go Build
↓
Go Test
↓
Docker Build
↓
Push Image to ACR
↓
Helm
↓
AKS
↓
Pod
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
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"
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 }}"
This means Helm takes:
image.repository
and:
image.tag
and combines them into:
registry/repository:tag
For example:
dessydevopsacr.azurecr.io/rest-go-k8s-example:12
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
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
The Service exposes the application outside the Kubernetes cluster.
The basic flow is:
Internet
↓
Azure Load Balancer
↓
Kubernetes Service
↓
Pod
↓
Go Application
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
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'
I deliberately used:
admin: 'false'
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
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'
I pinned the version rather than relying on an automatically resolved latest version.
The AKS context step then used:
use-kubelogin: 'true'
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
My AKS context was:
rest-go-aks
I could switch to it with:
kubectl config use-context rest-go-aks
Then verify the cluster:
kubectl get nodes
This was useful because it gave me a way to distinguish between:
Kubernetes problem
and:
GitHub Actions authentication problem
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 }}
There are a few things happening here.
First:
helm upgrade --install
means:
If the release exists, upgrade it. If it does not exist, install it.
The release name is:
go-rest-api
The namespace is:
go-rest-api
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>
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 }}
I changed the workflow to:
${{ github.run_number }}
Now the image tags look like:
:10
:11
:12
For example:
dessydevopsacr.azurecr.io/rest-go-k8s-example:12
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
as the image name.
The GitHub context provides:
github.event.repository.name
which returns the repository name.
For this repository:
REST-go-k8s-example
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"
This creates:
IMAGE_NAME=rest-go-k8s-example
The image can then be built using:
- name: Build Docker image
run: docker build -t dessydevopsacr.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.run_number }} .
And pushed using:
- name: Push Docker image
run: docker push dessydevopsacr.io/${{ env.IMAGE_NAME }}:${{ github.run_number }}
The actual registry should of course be:
dessydevopsacr.azurecr.io
So the final image becomes:
dessydevopsacr.azurecr.io/rest-go-k8s-example:12
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 }}
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
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
The image tag was based on the GitHub Actions run number.
For example:
rest-go-k8s-example:12
The complete image reference was:
dessydevopsacr.azurecr.io/rest-go-k8s-example:12
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
The release showed as deployed:
NAME NAMESPACE REVISION STATUS
go-rest-api go-rest-api 3 deployed
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
And if I wanted to see the Kubernetes resources:
kubectl get pods -n go-rest-api
kubectl get svc -n go-rest-api
kubectl get deployments -n go-rest-api
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}'
This lets me verify the exact image Kubernetes is configured to run.
The result was:
dessydevopsacr.azurecr.io/rest-go-k8s-example:12
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
29. The LoadBalancer Problem
One of the more interesting problems happened when I created the Kubernetes Service.
The Service was:
type: LoadBalancer
but when I checked it:
kubectl get svc -n go-rest-api
the external IP was:
<pending>
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
The Events section showed:
Warning CreateOrUpdatePublicIPAddress
ERROR CODE: PublicIPCountLimitReached
Cannot create more than 3 public IP addresses for this subscription in this region.
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
while I was now using:
go-rest-api
in the intended namespace:
go-rest-api
I removed the old Helm release:
helm uninstall go-rest-api -n default
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
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
The Service was now showing an external IP instead of:
<pending>
I then tested the application from outside the cluster:
curl http://<EXTERNAL-IP>:10000/health-check
The application returned:
{"healthy":true}
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
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
For running the application:
AKS
↓
Kubelet Identity
↓
AcrPull
↓
ACR
↓
Docker Image
↓
Pod
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
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
GitHub Actions needs permission to push the image.
AKS pulls the image
AKS Node/Kubelet
↓
AcrPull
↓
ACR
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
If I want to see Pods everywhere:
kubectl get pods -A
Deployments:
kubectl get deployments -A
Services:
kubectl get svc -A
All common resources:
kubectl get all -A
For Helm releases:
helm list -A
The -n option means namespace.
For example:
helm list -n go-rest-api
means:
Show Helm releases in the
go-rest-apinamespace.
And:
helm list -A
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
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
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
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>
the pipeline now creates:
<acr>/<github-repository-name>:<github-run-number>
For this project:
dessydevopsacr.azurecr.io/rest-go-k8s-example:12
The important parts are:
dessydevopsacr.azurecr.io
↓
ACR registry
rest-go-k8s-example
↓
GitHub repository name
12
↓
GitHub Actions run number
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
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
and found:
PublicIPCountLimitReached
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
Authorization
Authorization answers:
What are you allowed to do?
For example:
GitHub Actions Identity
↓
AcrPush
↓
ACR
and:
GitHub Actions Identity
↓
AKS RBAC permissions
↓
Kubernetes API
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
The final Docker image looked like:
dessydevopsacr.azurecr.io/rest-go-k8s-example:12
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
and returned:
{"healthy":true}
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
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
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?
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)