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 =