DEV Community

Desmond Goldsmith
Desmond Goldsmith

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

Then tested its health endpoint:

curl http://localhost:10000/health-check
Enter fullscreen mode Exit fullscreen mode

which returned:

{"healthy":true}
Enter fullscreen mode Exit fullscreen mode

I also built the Docker image locally:

docker build -t go-rest-api:1.0 .
Enter fullscreen mode Exit fullscreen mode

and verified that the container worked:

docker run --rm -p 10000:10000 go-rest-api:1.0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

Why pull_request?

For this exercise, the pipeline is triggered by a Pull Request:

on:
  pull_request:
Enter fullscreen mode Exit fullscreen mode

This allows us to validate changes before they are merged.

I created a test branch:

git checkout -b test-branch
Enter fullscreen mode Exit fullscreen mode

and pushed it:

git push -u origin test-branch
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I created an application named:

github-actions-rest-go-acr
Enter fullscreen mode Exit fullscreen mode

I selected:

Accounts in this organizational directory only
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

5. Why We Didn't Create a Client Secret

Inside the application registration, I opened:

Certificates & secrets
Enter fullscreen mode Exit fullscreen mode

Normally, we could create a client secret here.

I deliberately did not do that.

Instead, the application has:

Client secrets: 0
Federated credentials: 1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Azure provides several options.

I selected:

GitHub Actions deploying Azure resources
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I selected Pull request because the GitHub Actions workflow uses:

on:
  pull_request:
Enter fullscreen mode Exit fullscreen mode

Azure then generated the subject identifier for the trust relationship.

I named the credential:

github-actions-rest-go-acr-pr
Enter fullscreen mode Exit fullscreen mode

The audience remained:

api://AzureADTokenExchange
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

I selected:

Add
→ Add role assignment
Enter fullscreen mode Exit fullscreen mode

For the role, I selected:

AcrPush
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I selected:

User, group, or service principal
Enter fullscreen mode Exit fullscreen mode

Then:

Select members
Enter fullscreen mode Exit fullscreen mode

I searched for:

github-actions-rest-go-acr
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I created three repository secrets:

AZURE_CLIENT_ID
AZURE_TENANT_ID
AZURE_SUBSCRIPTION_ID
Enter fullscreen mode Exit fullscreen mode

The values correspond to:

AZURE_CLIENT_ID       = <Application Client ID>
AZURE_TENANT_ID       = <Directory/Tenant ID>
AZURE_SUBSCRIPTION_ID = <Azure Subscription ID>
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode

The important permission is:

id-token: write
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now the workflow can authenticate Docker against:

dessydevopsacr.azurecr.io
Enter fullscreen mode Exit fullscreen mode

15. Building the Docker Image

Instead of using a fixed tag such as:

go-rest-api:1.0
Enter fullscreen mode Exit fullscreen mode

I used the GitHub commit SHA:

- name: Build Docker image
  run: docker build -t dessydevopsacr.azurecr.io/go-rest-api:${{ github.sha }} .
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

18. Verifying the Artifact in Azure

After the workflow completed, I went back to:

Azure Portal
→ Container Registries
→ dessydevopsacr
→ Repositories
→ go-rest-api
Enter fullscreen mode Exit fullscreen mode

The repository showed:

Repository: go-rest-api
Tag count: 1
Manifest count: 1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)