zgba Network

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

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. 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 = AZURE_TENANT_ID = <Directory/Tenant 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: 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: 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.

View original article