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.
Project repository: github url
The goal for this exercise was straightforward:
Build a Go application, create a GitHub Actions pipeline, authenticate securely to Azure, build a Docker image, and push it to Azure Container Registry (ACR).
For this exercise, I deliberately stopped at ACR. Kubernetes and AKS deployment will come later.
What I wanted to achieve
The goal was to build a workflow that could:
- Run when a Pull Request is created
- Build and test the Go application
- Build a Docker image
- Authenticate to Azure without using a client secret
- Push the image to Azure Container Registry
The final flow looks like this:
Pull Request
↓
GitHub Actions
↓
Go Build & Test
↓
Docker Build
↓
GitHub OIDC
↓
Microsoft Entra ID
↓
Azure Container Registry
↓
Docker Image
1. The Practice Project
I used a small Go REST API project for the exercise.
The application itself wasn't the main focus. I mainly needed a working application that could:
- Build successfully with Go
- Run inside Docker
- Expose an HTTP endpoint
I verified the application locally with:
go run .
Then tested its health endpoint:
curl http://localhost:10000/health-check
which returned:
{"healthy":true}
I also built the Docker image locally:
docker build -t go-rest-api:1.0 .
and verified that the container worked:
docker run --rm -p 10000:10000 go-rest-api:1.0
At this point, I knew the application and Dockerfile were working.
2. Creating the GitHub Actions Pipeline
I created the workflow file:
.github/workflows/ci-cd.yml
The initial pipeline was intentionally simple. [find the final workflow below]
name: Go CI/CD
on:
pull_request:
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: Download dependencies
run: go mod download
- name: Build Go application
run: go build -v ./...
- name: Run Go tests
run: go test ./...
- name: Build Docker image
run: docker build -t go-rest-api:1.0 .
Why pull_request?
For this exercise, the pipeline is triggered by a Pull Request:
on:
pull_request:
This allows us to validate changes before they are merged.
I created a test branch:
git checkout -b test-branch
and pushed it:
git push -u origin test-branch
I then opened a Pull Request from test-branch into main.
GitHub Actions automatically started the workflow.
GitHub Pull Request showing the Actions check passing.
3. Preparing Azure
I already had an Azure Container Registry:
ACR name: dessydevopsacr
Login server: dessydevopsacr.azurecr.io
The goal was to allow GitHub Actions to push Docker images into this registry.
There are different ways to authenticate an application to Azure.
A traditional approach would be:
GitHub Actions → Client ID + Client Secret → Azure
The problem with this approach is that we would have a long-lived secret that needs to be stored and managed.
Instead, I chose OpenID Connect (OIDC).
Our authentication flow became:
GitHub Actions → OIDC token → Microsoft Entra ID → Azure
No Azure client secret is required.
4. Creating the Azure App Registration
In the Azure Portal, I went to:
Microsoft Entra ID
→ App registrations
→ New registration
I created an application named:
github-actions-rest-go-acr
I selected:
Accounts in this organizational directory only
After creating the application, Azure provided three important identifiers:
- Application (client) ID
- Object ID
- Directory (tenant) ID
The Application (client) ID is what identifies our application.

Azure Portal → App registrations → github-actions-rest-go-acr → Overview.
For a public article, I would recommend replacing your real IDs with placeholders such as:
<AZURE_CLIENT_ID>
<AZURE_TENANT_ID>
<AZURE_SUBSCRIPTION_ID>
5. Why We Didn't Create a Client Secret
Inside the application registration, I opened:
Certificates & secrets
Normally, we could create a client secret here.
I deliberately did not do that.
Instead, the application has:
Client secrets: 0
Federated credentials: 1
The federated credential is what allows GitHub Actions to authenticate without a stored Azure password/secret.
Azure Portal → App registration → Certificates & secrets showing the Federated credentials section.
6. Creating the Federated Credential
Inside:
Certificates & secrets
→ Federated credentials
→ Add credential
Azure provides several options.
I selected:
GitHub Actions deploying Azure resources
Federated credential creation screen.
Azure then asked for information about the GitHub repository.
For this project:
GitHub owner: Desmondgoldsmith
Repository: REST-go-k8s-example
Entity type: Pull request
I selected Pull request because the GitHub Actions workflow uses:
on:
pull_request:
Azure then generated the subject identifier for the trust relationship.
I named the credential:
github-actions-rest-go-acr-pr
The audience remained:
api://AzureADTokenExchange
7. Understanding What the Federated Credential Does
This was one of the most important concepts in the exercise.
The federated credential essentially establishes a trust relationship between GitHub and Azure.
When GitHub Actions runs, GitHub can provide an OIDC token.
Azure receives that token and checks whether it matches the trust relationship we configured.
So conceptually:
GitHub Actions
↓
OIDC token
↓
Microsoft Entra ID
↓
"Do I trust this GitHub identity?"
↓
Yes
↓
Application identity authenticated
But authentication alone isn't enough.
We also need to tell Azure what this identity is allowed to do.
That is where Azure RBAC comes in.
8. Giving the Application Permission to Push to ACR
I opened the actual ACR resource:
Azure Portal
→ Container Registries
→ dessydevopsacr
→ Access control (IAM)
I selected:
Add
→ Add role assignment
For the role, I selected:
AcrPush
AcrPush gives the identity permission to push and pull images from the registry.
I didn't give the application broad Contributor permissions because the GitHub Actions pipeline doesn't need them.
This is an example of least privilege.
** Role selection showing AcrPush.
9. Selecting the GitHub Actions Identity
For:
Assign access to
I selected:
User, group, or service principal
Then:
Select members
I searched for:
github-actions-rest-go-acr
and selected it.
Azure showed the selected identity as an application/service principal.
ACR role assignment showing github-actions-rest-go-acr.
The final assignment was:
github-actions-rest-go-acr
↓
AcrPush
↓
dessydevopsacr
So the identity used by GitHub Actions can push images, but only to the ACR resource we specified.
10. Adding Azure Credentials to GitHub
Next, I went to the GitHub repository:
Settings
→ Secrets and variables
→ Actions
→ Secrets
I created three repository secrets:
AZURE_CLIENT_ID
AZURE_TENANT_ID
AZURE_SUBSCRIPTION_ID
The values correspond to:
AZURE_CLIENT_ID = <Application Client ID>
AZURE_TENANT_ID = <Directory/Tenant ID>
AZURE_SUBSCRIPTION_ID = <Azure Subscription ID>

GitHub → Settings → Secrets and variables → Actions showing the secrets.
Notice that there is still no Azure client secret.
That's intentional.
11. Giving GitHub Actions Permission to Request an OIDC Token
The next change was in our workflow.
We added:
permissions:
id-token: write
contents: read
The important permission is:
id-token: write
This allows the GitHub Actions workflow to request an OIDC identity token.
contents: read allows the workflow to read the repository contents.
So our workflow now starts with:
name: Go CI/CD
on:
pull_request:
permissions:
id-token: write
contents: read
12. Logging Into Azure from GitHub Actions
We then added Microsoft's 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 pieces we've configured start working together.
The workflow requests an OIDC token.
Azure validates the token against our federated credential.
If everything matches, GitHub Actions becomes authenticated to Azure as:
github-actions-rest-go-acr
13. Testing Azure Authentication
I pushed the workflow change to my test branch and opened another Pull Request.
GitHub Actions ran again.
This time, the Azure login step passed successfully.
That was a major checkpoint because it proved that:
GitHub
↓
OIDC
↓
Microsoft Entra ID
↓
Federated Credential
↓
github-actions-rest-go-acr
was working correctly.
_ GitHub Actions run showing the Azure login step passing._
14. Logging Docker Into ACR
Authenticating the GitHub Actions runner to Azure isn't quite the same thing as authenticating Docker to ACR.
So I added:
- name: Log in to Azure Container Registry
run: az acr login --name dessydevopsacr
Now the workflow can authenticate Docker against:
dessydevopsacr.azurecr.io
15. Building the Docker Image
Instead of using a fixed tag such as:
go-rest-api:1.0
I used the GitHub commit SHA:
- name: Build Docker image
run: docker build -t dessydevopsacr.azurecr.io/go-rest-api:${{ github.sha }} .
This gives each build a unique tag based on the commit that triggered the workflow.
The image looks like:
dessydevopsacr.azurecr.io/go-rest-api:<commit-sha>
This is useful because we can trace an image back to the source commit that created it.
16. Pushing the Image to ACR
Finally, the workflow pushes the image:
- name: Push Docker image
run: docker push dessydevopsacr.azurecr.io/go-rest-api:${{ github.sha }}
At this point, the complete pipeline is:
Pull Request
↓
GitHub Actions
↓
Checkout
↓
Setup Go
↓
Build
↓
Test
↓
Azure OIDC Login
↓
ACR Login
↓
Docker Build
↓
Docker Push
↓
Azure Container Registry
17. The Final Workflow
Here is the complete workflow:
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: 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: Build Docker image
run: docker build -t dessydevopsacr.azurecr.io/go-rest-api:${{ github.sha }} .
- name: Push Docker image
run: docker push dessydevopsacr.azurecr.io/go-rest-api:${{ github.sha }}
18. Verifying the Artifact in Azure
After the workflow completed, I went back to:
Azure Portal
→ Container Registries
→ dessydevopsacr
→ Repositories
→ go-rest-api
The repository showed:
Repository: go-rest-api
Tag count: 1
Manifest count: 1
There was also a SHA-256 image digest.
Azure Container Registry → Repositories → go-rest-api showing the pushed image.
This confirmed that the pipeline had successfully built the Docker image and pushed it into Azure Container Registry.
What I Learned
The biggest lesson from this exercise wasn't the Docker build command.
It was understanding how the authentication pieces fit together.
Authentication
The federated credential answers:
Who is allowed to authenticate?
Authorization
The Azure RBAC role answers:
What is that identity allowed to do?
In our case:
GitHub Actions
↓
OIDC
↓
Microsoft Entra ID
↓
github-actions-rest-go-acr
↓
AcrPush
↓
Azure Container Registry
And because we're using OIDC, there is no long-lived Azure client secret sitting inside GitHub.
Final Result
We started with a simple Go application and ended with a working GitHub Actions CI/CD pipeline capable of publishing a Docker artifact to Azure Container Registry.
The final architecture is:
GitHub Pull Request
↓
GitHub Actions
↓
Go Build + Test
↓
Docker Build
↓
GitHub OIDC
↓
Microsoft Entra ID
↓
Azure RBAC
↓
Azure Container Registry
↓
go-rest-api:<commit-sha>
This gives us a solid foundation for the next stage of the migration — deploying the artifact from ACR into Kubernetes.
But for this exercise, the pipeline stops at ACR.
And that was the goal.







Top comments (0)