# CI/CD Pipeline Setup Reference Guide

## Introduction

A step-by-step guide for setting up a **GitHub Actions → Azure Container Registry (ACR)** CI/CD pipeline for a multi-service monorepo.

Follow the sections in order. Each section creates or configures something required by the next.

**End result:**

*   A push to `main` runs validation and builds every service, tagging each image with the Git commit SHA.
    
*   A version tag such as `v1.1.0` builds the services and adds both the version tag and `latest`.
    
*   GitHub Actions authenticates to Azure using **OIDC**, so no Azure client secret is stored in GitHub.
    

## 1\. Prerequisites

Before starting, make sure you have:

*   An Azure subscription containing an existing **Azure Container Registry (ACR)**
    
*   A GitHub repository containing the **applications you want to build, test, and deploy**
    
*   **VS Code (or any other code editor) installed**
    
*   Azure CLI (`az`) installed and authenticated:
    

```yaml
az login
```

*   `uv`/`pip` installed for running Python lint checks locally
    
*   Node.js and `npm` installed for running frontend lint checks locally
    

## 2\. Identify the Application Technology Stack

Before creating the workflow, identify the **programming languages, frameworks, package managers, and deployment technologies** used by the application.

The CI/CD workflow should be designed around the technologies the application actually uses. This determines which tools need to be installed, what validation checks need to run, and how the application should be built and packaged.

For example, Netiks Store uses:

| **Technology** | **CI/CD Requirement** |
| --- | --- |
| Python | Set up Python and run Python linting |
| FastAPI | Validate the Python backend services |
| Node.js / Next.js | Set up Node.js and run frontend linting |
| npm | Install frontend dependencies |
| Docker | Build and package the applications as container images |
| Docker Compose | Validate the container configuration |
| Azure Container Registry | Store the built container images |
| GitHub Actions | Automate the CI/CD process |

Because Netiks Store contains both **Python backend services and a Node.js/Next.js web application**, the workflow needs to support both environments before building the Docker images.

### Identify the Docker Build Requirements

For each application or service that will be built, identify:

*   The application name
    
*   The location of its Dockerfile
    
*   The Docker build context
    

For the Netiks Store application, the build information is:

| Application | Dockerfile | Build Context |
| --- | --- | --- |
| `web` | `infra/docker/web.Dockerfile` | `.` |
| `gateway` | `apps/gateway/Dockerfile` | `.` |
| `identity-service` | `services/identity-service/Dockerfile` | `.` |
| `vendor-service` | `services/vendor-service/Dockerfile` | `.` |
| `catalog-service` | `services/catalog-service/Dockerfile` | `.` |
| `media-service` | `services/media-service/Dockerfile` | `.` |
| `admin-service` | `services/admin-service/Dockerfile` | `.` |

This information will be used to create the **GitHub Actions build matrix**, allowing the same workflow to build each application separately.

If an application depends on files located elsewhere in the repository, use the repository root (`.`) as the Docker build context. Docker can only access files within its build context, so using the repository root ensures that required shared files and packages are available during the build.

## 3\. Create the Azure App Registration

GitHub Actions will authenticate to Azure as an Entra ID application through OIDC (Open ID Connect). No client secret is required.

Create the application: run

```yaml
az ad app create --display-name "<AppRegistrationName>"
```

replace the placeholder <AppRegistrationName> with a name of your choice

for example,

```yaml
az ad app create --display-name "github-actions-acr-push"
```

The command returns an `appId`. This is the application's **Client ID**.

You can also retrieve your Azure tenant and subscription IDs with:

```yaml
az account show --query "{tenant:tenantId, subscription:id}"
```

Record the following:

*   **Client ID**
    
*   **Tenant ID**
    
*   **Subscription ID**
    

You will use these values when configuring GitHub Actions.

## 4\. Grant the Application Permission to Push to ACR

Give the application the **AcrPush** role on your container registry:

```yaml
az role assignment create \
  --assignee <APP_ID> \
  --role AcrPush \
  --scope $(az acr show --name <ACR_NAME> --query id -o tsv)
```

Replace:

*   `<APP_ID>` with the Client ID from Step 3.
    
*   `<ACR_NAME>` with the registry's short name, without `.azurecr.io`.
    

For example:

```text
myregistry
```

rather than:

```text
myregistry.azurecr.io
```

The `AcrPush` role allows the GitHub Actions identity to authenticate to ACR and push container images.

## 5\. Configure GitHub OIDC Federation

Federated credentials establish trust between GitHub Actions and the Azure application.

Instead of storing a client secret, GitHub issues a short-lived OIDC token for the workflow. Azure verifies that token against the federated credential before allowing authentication.

*   Main branch credential
    

For workflows triggered by pushes to `main`:

```yaml
az ad app federated-credential create \
  --id <APP_ID> \
  --parameters '{
    "name": "<federated-credential name>",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:<owner>/<repo>:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'
```

*   Version tag credential
    

For a specific release tag:

```yaml
az ad app federated-credential create \
  --id <APP_ID> \
  --parameters '{
    "name": "<federated-credential name>",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:<owner>/<repo>:ref:refs/tags/v1.0.0",
    "audiences": ["api://AzureADTokenExchange"]
  }'
```

Replace

*   `<APP_ID>` with the Client ID from Step 3
    
*   <federated-credential name> with any name
    
*   `<owner>/<repo>` with the exact GitHub repository path. For example: `repo:examplerepo/myapp:ref:refs/heads/main`
    

The repository path must match the GitHub repository **exactly**.

### Verify the credentials

After creating the credentials, verify what Azure actually stored:

```yaml
az ad app federated-credential list --id <APP_ID>
```

Check the `subject` field carefully.

For the main branch it should look like:

```yaml
repo:<owner>/<repo>:ref:refs/heads/main
```

For a release tag:

```yaml
repo:<owner>/<repo>:ref:refs/tags/v1.0.0
```

There should be no additional GitHub user IDs, repository IDs, or other characters appended to the subject.

An incorrect subject causes errors such as:

```text
AADSTS700213: No matching federated identity record found
```

When troubleshooting OIDC failures, always inspect the actual federated credentials stored in Azure rather than relying only on what was entered in the portal.

## 6\. Configure GitHub Repository Variables

In GitHub, go to:

**Repository → Settings → Secrets and variables → Actions → Variables tab**

Add the following repository variables:

| Variable | Value |
| --- | --- |
| `AZURE_CLIENT_ID` | Client ID from Step 3 |
| `AZURE_TENANT_ID` | Tenant ID from Step 3 |
| `AZURE_SUBSCRIPTION_ID` | Subscription ID from Step 3 |
| `ACR_LOGIN_SERVER` | your full registry hostname, e.g. `myacr324.azurecr.io` |

These values can be repository variables because they are identifiers rather than authentication secrets. The security of the Azure authentication comes from the OIDC trust relationship and Azure role assignment.

![](https://cdn.hashnode.com/uploads/covers/66f14440bdc75fff00b3fa9d/7079ee2b-c74e-479d-9c4d-4df7eb71515b.png align="center")

## 7\. Create the GitHub Actions Workflow (Locally)

Create a workflow file in the application root:

```text
.github/workflows/build-and-push.yml
```

For Netiks store, the workflow contains two stages:

```yaml
validate
   ↓
build-and-push
```

The `build-and-push` job uses:

```yaml
needs: validate
```

This makes validation a quality gate. If validation fails, the Docker images are not built or pushed.

```yaml
name: Build and Push

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]

env:
  REGISTRY: ${{ vars.ACR_LOGIN_SERVER }}

jobs:
  validate:
    name: Validate
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install Python lint dependencies
        run: pip install ruff

      - name: Lint Python services
        run: ruff check .

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
          cache-dependency-path: package-lock.json

      - name: Install web application dependencies
        run: npm ci

      - name: Lint web application
        run: npm run lint:web

      - name: Validate Docker Compose configuration
        run: |
          cp .env.example .env 2>/dev/null || touch .env
          docker compose -f docker-compose.yml config --quiet

  build-and-push:
    name: Build and Push (${{ matrix.service }})
    needs: validate
    runs-on: ubuntu-latest

    permissions:
      contents: read
      id-token: write

    strategy:
      matrix:
        include:
          - service: <service>
            path: <apps/service-1>/Dockerfile
            context: .
          
        # Add one entry for every service 
        # replace <service> with exact service name 
        # replace <apps/service-1> with exact dockerfile path
        # Use the build context defined in Section 2.

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Azure #(OIDC — no client secret)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Log in to Azure Container Registry
        run: az acr login --name "${ACR_LOGIN_SERVER%%.*}"
        env:
          ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}

      - name: Determine image tags
        id: tags
        run: |
          TAGS="${{ env.REGISTRY }}/${{ matrix.service }}:${{ github.sha }}"

          if [ "${{ github.ref_type }}" = "tag" ]; then
            TAGS="${TAGS}
          ${{ env.REGISTRY }}/${{ matrix.service }}:${{ github.ref_name }}
          ${{ env.REGISTRY }}/${{ matrix.service }}:latest"
          fi

          {
            echo "tags<<EOF"
            echo "$TAGS"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: ./${{ matrix.context }}
          file: ./${{ matrix.path }}
          push: true
          tags: ${{ steps.tags.outputs.tags }}
          cache-from: type=gha,scope=${{ matrix.service }}
          cache-to: type=gha,mode=max,scope=${{ matrix.service }}
```

Update the `matrix.include` section of the workflow using the service paths documented in Section 2.

### Commit and push the workflow to github

```bash
git add .github/workflows/build-and-push.yml
git commit -m "ci: add build-and-push workflow"
git push origin main
```

## 8\. Trigger and Verify a Normal Build

Make a test change and push it to `main`:

```bash
git add .
git commit -m "test: verify pipeline"
git push origin main
```

In GitHub → **Actions**, verify that:

*   The `validate` job passes.
    
*   The matrix build jobs run successfully.
    
*   All services are built and pushed.
    

In Azure Portal:

**Container Registry → Repositories**

Verify that each service has an image tagged with the Git commit SHA.

For a normal `main` push, `latest` should **not** be updated.

The expected behavior is:

```text
main push
   ↓
<service>:<git-sha>
```

![](https://cdn.hashnode.com/uploads/covers/66f14440bdc75fff00b3fa9d/253cf2cf-e648-41d5-aeea-fc2f44cfdaed.png align="center")

## 9\. Trigger and Verify a Version Release

Create a version tag:

```bash
git tag v1.0.0
git push origin v1.0.0
```

The workflow recognizes tags matching:

```text
v*.*.*
```

![](https://cdn.hashnode.com/uploads/covers/66f14440bdc75fff00b3fa9d/b252b350-090b-4f3e-8975-4dfc69dc7915.png align="center")

For a release such as `v1.0.0`, each service receives:

```text
<service>:<git-sha>
<service>:v1.0.0
<service>:latest
```

The three tags should point to the same image digest.

This gives you both:

*   **Traceability** — the Git SHA identifies the source commit.
    
*   **Release identification** — `v1.0.0` identifies the application version.
    
*   **Convenience** — `latest` points to the most recently released version.
    

## 10\. Deploy a Released Version

Create or update `docker-compose.prod.yml` and pin each service to the released image, for example:

```yaml
services:
  <service-1>:
    image: <ACR_LOGIN_SERVER>/<service-1>:v1.0.0

  <service-2>:
    image: <ACR_LOGIN_SERVER>/<service-2>:v1.0.0
```

Using an explicit version tag is preferable to deploying `latest` because the deployed version is immediately identifiable and reproducible.

Commit and push the production Compose file:

```yaml
git add docker-compose.prod.yml
git commit -m "deploy: release v1.0.0"
git push origin main
```

On the production VM:

```yaml
git pull

docker compose \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  pull <service>

docker compose \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  up -d <service>
```

Replace `<service>` with the service being deployed.

To update all services, omit the service name:

```yaml
docker compose \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  pull

docker compose \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  up -d
```

### Verify the deployment

Check the running containers:

```bash
docker ps
```

Then verify the application itself using a browser or `curl`.

A successful deployment should be verified at the application level, not only by confirming that the container is running.

## 11\. Release a Future Version

For a future release:

```bash
git tag v1.1.0
git push origin v1.1.0
```

The pipeline will build and publish:

```text
<service>:<git-sha>
<service>:v1.1.0
<service>:latest
```

Then update the relevant service image in `docker-compose.prod.yml`:

```yaml
image: <ACR_LOGIN_SERVER>/<service>:v1.1.0
```

Commit the change and redeploy using the commands in Section 10.

## OIDC consideration for future tags

If Azure is configured with an exact federated credential for `v1.0.0`, that credential does **not automatically authorize** `v1.1.0`.

Create a matching credential for the new tag unless your Azure configuration uses an appropriate supported wildcard pattern.

Verify the configured subjects with:

```bash
az ad app federated-credential list --id <APP_ID>
```

The important point is that the GitHub OIDC subject must match the workflow's repository and Git reference exactly.
