PROJECTIntermediate

CI/CD Pipeline with GitHub Actions and Azure

Build a secure CI/CD pipeline with GitHub Actions deploying to Azure. Covers build, test, security scanning (SAST/DAST), and deployment with OIDC...

Dylan H.

DevOps Engineering

February 3, 2026
11 min read
4-6 hours
CI/CD Pipeline with GitHub Actions and Azure

Tools & Technologies

GitHub ActionsAzure CLIDockerTerraform

CI/CD Pipeline with GitHub Actions and Azure

Build an enterprise-grade CI/CD pipeline using GitHub Actions with secure deployment to Azure. This project implements security scanning, automated testing, and OIDC-based authentication for zero-secret deployments.

Project Overview

What We're Building

┌─────────────────────────────────────────────────────────────────────┐
│                    CI/CD Pipeline Architecture                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Developer                                                          │
│      │                                                              │
│      ▼                                                              │
│  ┌──────────────┐                                                   │
│  │   GitHub     │                                                   │
│  │  Repository  │                                                   │
│  └──────┬───────┘                                                   │
│         │ Push/PR                                                   │
│         ▼                                                           │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                    GitHub Actions                             │  │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐         │  │
│  │  │  Build  │─▶│  Test   │─▶│Security │─▶│ Deploy  │         │  │
│  │  │         │  │         │  │ Scan    │  │         │         │  │
│  │  └─────────┘  └─────────┘  └─────────┘  └────┬────┘         │  │
│  └──────────────────────────────────────────────┼───────────────┘  │
│                                                 │                   │
│                               ┌─────────────────┼─────────────────┐│
│                               │                 ▼                  ││
│                               │    ┌─────────────────────┐        ││
│                               │    │   Azure (OIDC)      │        ││
│                               │    │   No Secrets!       │        ││
│                               │    └──────────┬──────────┘        ││
│                               │               │                    ││
│                               │    ┌──────────┼──────────┐        ││
│                               │    ▼          ▼          ▼        ││
│                               │ ┌─────┐   ┌─────┐   ┌─────────┐  ││
│                               │ │ ACR │   │ AKS │   │App Svc  │  ││
│                               │ └─────┘   └─────┘   └─────────┘  ││
│                               └────────────────────────────────────┘│
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Prerequisites

  • GitHub repository with application code
  • Azure subscription
  • Azure CLI installed locally
  • Docker installed locally
  • Basic understanding of GitHub Actions

Part 1: Azure Infrastructure Setup

Step 1: Create Azure Resources

# Variables
RESOURCE_GROUP="rg-webapp-prod"
LOCATION="eastus"
ACR_NAME="acrwebappprod"
APP_SERVICE_PLAN="asp-webapp-prod"
WEB_APP_NAME="webapp-prod-$(openssl rand -hex 4)"
 
# Create resource group
az group create --name $RESOURCE_GROUP --location $LOCATION
 
# Create Azure Container Registry
az acr create \
  --resource-group $RESOURCE_GROUP \
  --name $ACR_NAME \
  --sku Standard \
  --admin-enabled false
 
# Create App Service Plan
az appservice plan create \
  --resource-group $RESOURCE_GROUP \
  --name $APP_SERVICE_PLAN \
  --is-linux \
  --sku P1v3
 
# Create Web App
az webapp create \
  --resource-group $RESOURCE_GROUP \
  --plan $APP_SERVICE_PLAN \
  --name $WEB_APP_NAME \
  --deployment-container-image-name mcr.microsoft.com/appsvc/staticsite:latest
 
# Configure Web App to use ACR
az webapp config container set \
  --resource-group $RESOURCE_GROUP \
  --name $WEB_APP_NAME \
  --docker-custom-image-name "$ACR_NAME.azurecr.io/webapp:latest" \
  --docker-registry-server-url "https://$ACR_NAME.azurecr.io"

Step 2: Configure OIDC Authentication

Create Azure AD Application:

# Create app registration
APP_ID=$(az ad app create \
  --display-name "GitHub-Actions-OIDC" \
  --query appId -o tsv)
 
# Create service principal
az ad sp create --id $APP_ID
 
# Get subscription and tenant IDs
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
 
# Configure federated credential for GitHub
GITHUB_ORG="your-github-org"
GITHUB_REPO="your-repo-name"
 
az ad app federated-credential create \
  --id $APP_ID \
  --parameters "{
    \"name\": \"github-main-branch\",
    \"issuer\": \"https://token.actions.githubusercontent.com\",
    \"subject\": \"repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/main\",
    \"audiences\": [\"api://AzureADTokenExchange\"]
  }"
 
# Add credential for pull requests (optional)
az ad app federated-credential create \
  --id $APP_ID \
  --parameters "{
    \"name\": \"github-pull-request\",
    \"issuer\": \"https://token.actions.githubusercontent.com\",
    \"subject\": \"repo:${GITHUB_ORG}/${GITHUB_REPO}:pull_request\",
    \"audiences\": [\"api://AzureADTokenExchange\"]
  }"
 
# Grant permissions to resource group
az role assignment create \
  --assignee $APP_ID \
  --role Contributor \
  --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP"
 
# Grant ACR push permission
ACR_ID=$(az acr show --name $ACR_NAME --query id -o tsv)
az role assignment create \
  --assignee $APP_ID \
  --role AcrPush \
  --scope $ACR_ID
 
# Output values for GitHub secrets
echo "AZURE_CLIENT_ID: $APP_ID"
echo "AZURE_TENANT_ID: $TENANT_ID"
echo "AZURE_SUBSCRIPTION_ID: $SUBSCRIPTION_ID"

Step 3: Configure GitHub Secrets

Navigate to: Repository → Settings → Secrets and variables → Actions

Add these secrets:

Secret NameValue
AZURE_CLIENT_IDApp registration Application ID
AZURE_TENANT_IDAzure AD Tenant ID
AZURE_SUBSCRIPTION_IDAzure Subscription ID
ACR_NAMEAzure Container Registry name
WEBAPP_NAMEAzure Web App name

Part 2: GitHub Actions Workflow

Step 4: Create Main CI/CD Workflow

# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
env:
  ACR_NAME: ${{ secrets.ACR_NAME }}
  WEBAPP_NAME: ${{ secrets.WEBAPP_NAME }}
  IMAGE_NAME: webapp
 
permissions:
  id-token: write
  contents: read
  security-events: write
  pull-requests: write
 
jobs:
  # ===================
  # BUILD JOB
  # ===================
  build:
    name: Build Application
    runs-on: ubuntu-latest
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
 
      - name: Install dependencies
        run: npm ci
 
      - name: Build application
        run: npm run build
 
      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7
 
  # ===================
  # TEST JOB
  # ===================
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    needs: build
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
 
      - name: Install dependencies
        run: npm ci
 
      - name: Run unit tests
        run: npm test -- --coverage
 
      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          fail_ci_if_error: false
 
  # ===================
  # SECURITY SCANNING
  # ===================
  security-scan:
    name: Security Scanning
    runs-on: ubuntu-latest
    needs: build
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      # SAST - Static Application Security Testing
      - name: Run CodeQL Analysis
        uses: github/codeql-action/init@v3
        with:
          languages: javascript
 
      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
 
      # Dependency Scanning
      - name: Run npm audit
        run: npm audit --audit-level=high
        continue-on-error: true
 
      - name: Run Snyk security scan
        uses: snyk/actions/node@master
        continue-on-error: true
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high
 
      # Secret Scanning
      - name: Scan for secrets
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD
 
  # ===================
  # DOCKER BUILD
  # ===================
  docker-build:
    name: Build Docker Image
    runs-on: ubuntu-latest
    needs: [test, security-scan]
    if: github.event_name == 'push'
 
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
 
      # Login to Azure
      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
 
      # Login to ACR
      - name: Login to ACR
        run: az acr login --name ${{ env.ACR_NAME }}
 
      # Generate Docker metadata
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      # Build and push
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
 
      # Scan container image
      - name: Scan container image
        uses: azure/container-scan@v0
        with:
          image-name: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
          severity-threshold: HIGH
 
  # ===================
  # DEPLOY TO STAGING
  # ===================
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: docker-build
    if: github.ref == 'refs/heads/develop'
    environment:
      name: staging
      url: https://${{ secrets.STAGING_WEBAPP_NAME }}.azurewebsites.net
 
    steps:
      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
 
      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ secrets.STAGING_WEBAPP_NAME }}
          images: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
 
      - name: Health check
        run: |
          sleep 30
          curl -f https://${{ secrets.STAGING_WEBAPP_NAME }}.azurewebsites.net/health || exit 1
 
  # ===================
  # DEPLOY TO PRODUCTION
  # ===================
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: docker-build
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://${{ secrets.WEBAPP_NAME }}.azurewebsites.net
 
    steps:
      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
 
      - name: Deploy to Azure Web App
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ secrets.WEBAPP_NAME }}
          images: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
 
      - name: Health check
        run: |
          sleep 30
          curl -f https://${{ secrets.WEBAPP_NAME }}.azurewebsites.net/health || exit 1
 
      - name: Create deployment summary
        run: |
          echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY
          echo "- **Environment:** Production" >> $GITHUB_STEP_SUMMARY
          echo "- **Image:** ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
          echo "- **URL:** https://${{ secrets.WEBAPP_NAME }}.azurewebsites.net" >> $GITHUB_STEP_SUMMARY

Step 5: Create Dockerfile

# Dockerfile
# Build stage
FROM node:20-alpine AS builder
 
WORKDIR /app
 
COPY package*.json ./
RUN npm ci --only=production
 
COPY . .
RUN npm run build
 
# Production stage
FROM node:20-alpine AS production
 
WORKDIR /app
 
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001
 
# Copy built assets
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
 
USER nodejs
 
EXPOSE 3000
 
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
 
CMD ["node", "dist/server.js"]

Part 3: Security Scanning Deep Dive

Step 6: Configure SAST (Static Analysis)

# .github/workflows/security.yml
name: Security Scanning
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'  # Weekly on Monday
 
permissions:
  security-events: write
  contents: read
 
jobs:
  codeql:
    name: CodeQL Analysis
    runs-on: ubuntu-latest
 
    strategy:
      fail-fast: false
      matrix:
        language: ['javascript']
 
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
 
      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
          queries: security-extended
 
      - name: Autobuild
        uses: github/codeql-action/autobuild@v3
 
      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: "/language:${{ matrix.language }}"
 
  dependency-review:
    name: Dependency Review
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
 
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
 
      - name: Dependency Review
        uses: actions/dependency-review-action@v4
        with:
          fail-on-severity: high
          deny-licenses: GPL-3.0, AGPL-3.0

Step 7: Configure DAST (Dynamic Analysis)

# .github/workflows/dast.yml
name: DAST Scanning
 
on:
  workflow_run:
    workflows: ["CI/CD Pipeline"]
    types:
      - completed
    branches: [develop]
 
jobs:
  dast-scan:
    name: OWASP ZAP Scan
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
 
    steps:
      - name: Run OWASP ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.12.0
        with:
          target: 'https://${{ secrets.STAGING_WEBAPP_NAME }}.azurewebsites.net'
          rules_file_name: '.zap/rules.tsv'
          cmd_options: '-a'
 
      - name: Upload ZAP Report
        uses: actions/upload-artifact@v4
        with:
          name: zap-report
          path: report_html.html

Part 4: Infrastructure as Code

Step 8: Terraform for Azure Infrastructure

# terraform/main.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
 
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "stterraformstate"
    container_name       = "tfstate"
    key                  = "webapp.tfstate"
  }
}
 
provider "azurerm" {
  features {}
}
 
variable "environment" {
  type    = string
  default = "prod"
}
 
variable "location" {
  type    = string
  default = "eastus"
}
 
resource "azurerm_resource_group" "main" {
  name     = "rg-webapp-${var.environment}"
  location = var.location
}
 
resource "azurerm_container_registry" "main" {
  name                = "acrwebapp${var.environment}"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  sku                 = "Standard"
  admin_enabled       = false
}
 
resource "azurerm_service_plan" "main" {
  name                = "asp-webapp-${var.environment}"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  os_type             = "Linux"
  sku_name            = "P1v3"
}
 
resource "azurerm_linux_web_app" "main" {
  name                = "webapp-${var.environment}-${random_id.suffix.hex}"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  service_plan_id     = azurerm_service_plan.main.id
 
  site_config {
    always_on = true
 
    application_stack {
      docker_image_name   = "${azurerm_container_registry.main.login_server}/webapp:latest"
      docker_registry_url = "https://${azurerm_container_registry.main.login_server}"
    }
  }
 
  identity {
    type = "SystemAssigned"
  }
}
 
resource "random_id" "suffix" {
  byte_length = 4
}
 
output "webapp_url" {
  value = "https://${azurerm_linux_web_app.main.default_hostname}"
}

Step 9: Terraform Workflow

# .github/workflows/terraform.yml
name: Terraform Infrastructure
 
on:
  push:
    branches: [main]
    paths:
      - 'terraform/**'
  pull_request:
    branches: [main]
    paths:
      - 'terraform/**'
 
permissions:
  id-token: write
  contents: read
  pull-requests: write
 
jobs:
  terraform:
    name: Terraform
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: terraform
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
 
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0
 
      - name: Terraform Init
        run: terraform init
 
      - name: Terraform Format
        run: terraform fmt -check
 
      - name: Terraform Validate
        run: terraform validate
 
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        continue-on-error: true
 
      - name: Comment PR with Plan
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const output = `#### Terraform Plan 📖
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\`
            `;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
 
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve tfplan

Verification Checklist

Azure Setup:

  • Resource group created
  • ACR created and configured
  • App Service Plan created
  • Web App configured
  • OIDC authentication working

GitHub Configuration:

  • Secrets configured
  • Workflow files created
  • Branch protection enabled
  • Environment approvals configured

Pipeline Testing:

  • Build job succeeds
  • Tests pass
  • Security scans complete
  • Docker image pushed
  • Deployment succeeds

Resources


Questions? Reach out in our community Discord!