Intermediate

Integrating Azure Functions with CI/CD Pipelines

Ship Functions with Azure DevOps and GitHub Actions, Bicep, multi-environment strategies and rollback.

Level: Intermediate
Technologies: Azure DevOps / GitHub Actions / Bicep / Azure Functions v4 / .NET 8


About this guide: This document is a comprehensive reference on integrating Azure Functions into professional CI/CD pipelines. It covers Azure DevOps Pipelines, GitHub Actions, Infrastructure as Code with Bicep, multi-environment deployment strategies, automated testing, and DevOps best practices.


Table of Contents

  1. Why CI/CD for Azure Functions
  2. Deployment with Azure DevOps
  3. Infrastructure as Code with Bicep
  4. Deployment with GitHub Actions
  5. Multi-Environment Deployment Strategies
  6. Automated Testing in the Pipeline
  7. Secrets and Secure Configuration
  8. Rollback and Incident Management
  9. DevOps Best Practices
  10. Glossary

Module 1 – Why CI/CD for Azure Functions? {#module-1}

The Value of CI/CD

CI/CD (Continuous Integration / Continuous Deployment) fundamentally transforms how teams deliver Azure Functions applications. Instead of risky and infrequent manual deployments, CI/CD pipelines enable automated, reproducible, and frequent deliveries.

flowchart LR
    subgraph "Without CI/CD (Manual)"
        DEV1[Developer] -->|Deploys manually| AZURE1[Azure\nProduction]
        DEV1 -->|Sometimes different| AZURE1
        DEV1 -->|Frequent errors| ERROR1[❌ Incidents]
    end
    
    subgraph "With CI/CD (Automated)"
        DEV2[Developer] -->|git push| GIT[Git Repository]
        GIT -->|Triggers automatically| PIPELINE[CI/CD Pipeline]
        PIPELINE -->|Build + Test| BUILD[✅ Validated build]
        BUILD -->|Deploy TEST| TEST[Test Environment]
        TEST -->|Deploy PROD| PROD[Azure Production]
        PROD --> SUCCESS[✅ Reproducible deployment]
    end
    
    style PIPELINE fill:#0078D4,color:#fff
    style SUCCESS fill:#107C10,color:#fff
    style ERROR1 fill:#D83B01,color:#fff

Concrete CI/CD Benefits

BenefitDescriptionMeasurable Impact
Reduced time-to-marketDeploy as soon as a feature is completeFrom weeks to hours
Fewer errorsAutomation = eliminate manual errors-80% post-deployment incidents
Easy rollbackRe-run the pipeline for the previous versionRollback in < 10 minutes
Cost-effectiveOne-time investment, reusableROI > 1000% over 1 year
ReproducibilitySame code → same result every time100% consistency
TransparencyLogs: who deployed what and whenComplete audit trail
Automated testsRegression detected before productionGuaranteed quality
Infrastructure as CodeInfrastructure versioned like codeIdentical environments

Anatomy of a CI/CD Pipeline for Azure Functions

flowchart TD
    subgraph "Stage 1: Continuous Integration (CI)"
        A[Code push to Git] --> B[.NET Build]
        B --> C[Unit tests]
        C --> D[Integration tests]
        D --> E[Static code analysis]
        E --> F[ZIP package creation]
        F --> G[Artifact storage]
    end
    
    subgraph "Stage 2: Deploy Test"
        H[Download artifact] --> I[Deploy Infrastructure\nBicep/ARM]
        I --> J[Deploy code\nFunction App]
        J --> K[Smoke tests]
        K --> L[End-to-end tests]
    end
    
    subgraph "Stage 3: Deploy Production"
        M["Manual approval\n(optional)"] --> N[Deploy Infrastructure]
        N --> O[Deploy code\nSlot swap]
        O --> P[Production smoke tests]
        P --> Q[Monitoring alert check]
    end
    
    G --> H
    L --> M
    
    style A fill:#68217A,color:#fff
    style Q fill:#107C10,color:#fff

CI/CD Tools for Azure Functions

ToolDescription
Azure DevOps PipelinesYAML-based, integrated with Azure, service connections
GitHub ActionsYAML workflows in GitHub, actions marketplace

Module 2 – Deployment with Azure DevOps {#module-2}

Azure DevOps Concepts

ConceptDescription
OrganizationTop level - groups all company projects
ProjectWorkspace with repos, pipelines, boards
RepositoryGit repository hosted in Azure DevOps
PipelineYAML or classic definition of the CI/CD workflow
StageLogical grouping of steps (Build, Test, Production)
JobSet of steps running on an agent
StepIndividual task (script, predefined task)
AgentVirtual machine executing jobs
Service ConnectionSecure connection to Azure (credential store)
ArtifactFile produced by the pipeline (ZIP, Docker image)
Variable GroupGroup of shared variables between pipelines

Complete YAML Pipeline Structure

Demo.FunctionApp/
├── Demo.FunctionApp.csproj
├── HelloWorldFunction.cs
├── HelloYouFunction.cs
├── host.json
└── local.settings.json

Demo.FunctionApp.Tests/
├── Demo.FunctionApp.Tests.csproj
└── HelloYouFunctionTests.cs

Demo.Deployment/
├── pipeline/
│   ├── main.yaml           ← Main pipeline
│   ├── deployment.yaml     ← Deployment template
│   └── build.yaml          ← Build template
└── bicep/
    └── function-app.bicep  ← Infrastructure as Code

Complete Azure DevOps Pipeline (main.yaml)

# azure-pipelines.yml - Complete CI/CD pipeline for Azure Functions
trigger:
  branches:
    include:
    - main
    - release/*
  paths:
    exclude:
    - '**/*.md'
    - 'docs/**'

pr:
  branches:
    include:
    - main
  drafts: false

variables:
  dotnetVersion: '8.0.x'
  buildConfiguration: 'Release'
  functionAppProject: 'Demo.FunctionApp/Demo.FunctionApp.csproj'
  testProject: 'Demo.FunctionApp.Tests/Demo.FunctionApp.Tests.csproj'
  artifactName: 'function-package'
  bicepArtifactName: 'bicep-package'

stages:
# ==========================================
# STAGE 1: BUILD AND TESTS
# ==========================================
- stage: Build
  displayName: '🔨 Build and Tests'
  jobs:
  - job: BuildAndTest
    displayName: 'Build, Test and Package'
    pool:
      vmImage: 'ubuntu-latest'
    
    steps:
    # Configure .NET SDK
    - task: UseDotNet@2
      displayName: 'Configure .NET $(dotnetVersion)'
      inputs:
        version: $(dotnetVersion)
    
    # Restore NuGet packages
    - task: DotNetCoreCLI@2
      displayName: '📦 Restore dependencies'
      inputs:
        command: 'restore'
        projects: '**/*.csproj'
    
    # Build
    - task: DotNetCoreCLI@2
      displayName: '🔨 Build $(buildConfiguration)'
      inputs:
        command: 'build'
        projects: $(functionAppProject)
        arguments: '--configuration $(buildConfiguration) --no-restore'
    
    # Unit tests with coverage
    - task: DotNetCoreCLI@2
      displayName: '🧪 Unit tests'
      inputs:
        command: 'test'
        projects: $(testProject)
        arguments: >
          --configuration $(buildConfiguration) 
          --no-build 
          --logger trx 
          --results-directory $(Agent.TempDirectory)/TestResults
          --collect:"XPlat Code Coverage"
    
    # Publish test results
    - task: PublishTestResults@2
      displayName: '📊 Publish test results'
      condition: always()
      inputs:
        testResultsFormat: 'VSTest'
        testResultsFiles: '$(Agent.TempDirectory)/TestResults/**/*.trx'
        failTaskOnFailedTests: true
    
    # Publish code coverage
    - task: PublishCodeCoverageResults@2
      displayName: '📈 Publish code coverage'
      inputs:
        codeCoverageTool: 'Cobertura'
        summaryFileLocation: '$(Agent.TempDirectory)/TestResults/**/coverage.cobertura.xml'
        failIfCoverageEmpty: true
    
    # Publish the Function App package
    - task: DotNetCoreCLI@2
      displayName: '📦 Publish package'
      inputs:
        command: 'publish'
        publishWebProjects: false
        projects: $(functionAppProject)
        arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)/functions --no-build'
        zipAfterPublish: true
    
    # Copy Bicep files
    - task: CopyFiles@2
      displayName: '📋 Copy Bicep files'
      inputs:
        sourceFolder: 'Demo.Deployment/bicep'
        contents: '**/*.bicep'
        targetFolder: '$(Build.ArtifactStagingDirectory)/bicep'
    
    # Store artifacts
    - task: PublishBuildArtifacts@1
      displayName: '💾 Store function package'
      inputs:
        pathToPublish: '$(Build.ArtifactStagingDirectory)/functions'
        artifactName: $(artifactName)
    
    - task: PublishBuildArtifacts@1
      displayName: '💾 Store bicep package'
      inputs:
        pathToPublish: '$(Build.ArtifactStagingDirectory)/bicep'
        artifactName: $(bicepArtifactName)

# ==========================================
# STAGE 2: DEPLOYMENT TO TEST
# ==========================================
- stage: DeployTest
  displayName: '🧪 Test Deployment'
  dependsOn: Build
  condition: succeeded()
  variables:
  - group: 'azure-functions-test-vars'
  jobs:
  - template: pipeline/deployment.yaml
    parameters:
      environment: 'test'
      azureServiceConnection: 'azure-service-connection-test'
      resourceGroup: 'rg-functions-test'

# ==========================================
# STAGE 3: DEPLOYMENT TO PRODUCTION
# ==========================================
- stage: DeployProduction
  displayName: '🚀 Production Deployment'
  dependsOn: DeployTest
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  variables:
  - group: 'azure-functions-prod-vars'
  jobs:
  - deployment: DeployToProduction
    displayName: 'Deploy to Production'
    environment: 'production'  # Requires manual approval
    strategy:
      runOnce:
        deploy:
          steps:
          - template: pipeline/deployment.yaml
            parameters:
              environment: 'prod'
              azureServiceConnection: 'azure-service-connection-prod'
              resourceGroup: 'rg-functions-prod'

Reusable Deployment Template (deployment.yaml)

# Demo.Deployment/pipeline/deployment.yaml
parameters:
- name: environment
  type: string
- name: azureServiceConnection
  type: string
- name: resourceGroup
  type: string

steps:
# Download artifacts
- task: DownloadBuildArtifacts@1
  displayName: '⬇️ Download function package'
  inputs:
    buildType: 'current'
    downloadType: 'single'
    artifactName: 'function-package'
    downloadPath: '$(Pipeline.Workspace)'

- task: DownloadBuildArtifacts@1
  displayName: '⬇️ Download bicep package'
  inputs:
    buildType: 'current'
    downloadType: 'single'
    artifactName: 'bicep-package'
    downloadPath: '$(Pipeline.Workspace)'

# Deploy infrastructure with Bicep
- task: AzureCLI@2
  displayName: '🏗️ Deploy Infrastructure (Bicep)'
  name: DeployInfra
  inputs:
    azureSubscription: '${{ parameters.azureServiceConnection }}'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      set -e
      
      echo "Deploying infrastructure for environment: ${{ parameters.environment }}"
      
      # Create resource group if necessary
      az group create \
        --name ${{ parameters.resourceGroup }} \
        --location eastus \
        --tags environment=${{ parameters.environment }} managedBy=pipeline
      
      # Deploy the Bicep template
      DEPLOYMENT_OUTPUT=$(az deployment group create \
        --resource-group ${{ parameters.resourceGroup }} \
        --template-file $(Pipeline.Workspace)/bicep-package/function-app.bicep \
        --parameters environment=${{ parameters.environment }} \
        --output json)
      
      # Extract the Function App name from outputs
      APP_NAME=$(echo $DEPLOYMENT_OUTPUT | jq -r '.properties.outputs.functionAppName.value')
      echo "Function App deployed: $APP_NAME"
      
      # Make the name available for subsequent steps
      echo "##vso[task.setvariable variable=functionAppName;isOutput=true]$APP_NAME"

# Deploy the Function App code
- task: AzureFunctionApp@2
  displayName: '🚀 Deploy Azure Function code'
  inputs:
    azureSubscription: '${{ parameters.azureServiceConnection }}'
    appType: 'functionApp'
    appName: '$(DeployInfra.functionAppName)'
    package: '$(Pipeline.Workspace)/function-package/**/*.zip'
    deploymentMethod: 'zipDeploy'
    appSettings: |
      -ASPNETCORE_ENVIRONMENT ${{ parameters.environment }}
      -APPLICATIONINSIGHTS_CONNECTION_STRING $(ApplicationInsightsConnectionString)

# Post-deployment smoke tests
- task: PowerShell@2
  displayName: '🔍 Smoke tests'
  inputs:
    targetType: 'inline'
    script: |
      $baseUrl = "https://$(DeployInfra.functionAppName).azurewebsites.net"
      
      # Test 1: Health check
      Write-Host "Test 1: Health check..."
      $health = Invoke-RestMethod -Uri "$baseUrl/api/health" -Method Get
      if ($health.status -ne "healthy") {
        throw "Health check failed: $($health.status)"
      }
      Write-Host "✅ Health check OK"
      
      # Test 2: Hello World
      Write-Host "Test 2: HelloWorld function..."
      $response = Invoke-RestMethod -Uri "$baseUrl/api/hello?code=$env:FUNCTION_KEY" -Method Get
      Write-Host "✅ HelloWorld OK: $response"
      
      Write-Host "🎉 All smoke tests passed!"
  env:
    FUNCTION_KEY: $(FunctionHostKey)

Service Connection: Secure Configuration

A Service Connection in Azure DevOps is a secure connection that allows your pipelines to access Azure without storing credentials in code.

flowchart LR
    Pipeline[Azure DevOps\nPipeline] -->|Uses| SC[Service Connection]
    SC -->|Microsoft Entra ID\nWorkload Identity| Azure[Azure Subscription]
    Azure --> RG[Resource Group]
    RG --> FA[Function App]
    RG --> ST[Storage Account]
    
    subgraph "Authentication Types"
        WIF[Workload Identity\nFederation\n✅ Recommended]
        SP[Service Principal\nClient Secret\n⚠️ Acceptable]
        MSI[Managed Service\nIdentity\n✅ Excellent]
    end
    
    style WIF fill:#107C10,color:#fff
    style MSI fill:#107C10,color:#fff
# Create a Service Connection via Azure CLI
# Option 1: Workload Identity Federation (recommended - no secret)
az ad sp create-for-rbac \
  --name "sp-azure-devops-functions" \
  --role "Contributor" \
  --scopes "/subscriptions/{subscription-id}/resourceGroups/rg-functions-prod" \
  --json-auth

Module 3 – Infrastructure as Code with Bicep {#module-3}

Infrastructure as Code Philosophy

All code runs on something. For an Azure Function App, that “something” is Azure infrastructure. Infrastructure as Code involves describing this infrastructure in versioned files (Bicep, ARM, Terraform) rather than creating it manually in the portal (“ClickOps”).

flowchart TD
    subgraph "ClickOps (Avoid)"
        A1[Admin] -->|Manual clicks| B1[Azure Portal]
        B1 -->|Unpredictable result| C1[Undocumented infrastructure]
        C1 -->|Impossible to reproduce| D1[❌ Inconsistencies between environments]
    end
    
    subgraph "Infrastructure as Code (Recommended)"
        A2[Bicep/Terraform file\nVersioned in Git] -->|CI/CD Pipeline| B2[Bicep Deployment]
        B2 -->|Idempotent| C2[Azure Infrastructure]
        C2 -->|Identical across all environments| D2[✅ Test = Staging = Production]
    end
    
    style D2 fill:#107C10,color:#fff
    style D1 fill:#D83B01,color:#fff

Complete Bicep Template for Azure Functions

// function-app.bicep - Complete infrastructure for Azure Functions
// Parameters
@allowed(['test', 'staging', 'prod'])
@description('Target environment')
param environment string

@description('Azure region')
param location string = resourceGroup().location

@description('Memory size for functions (MB)')
param functionMemoryMb int = 256

// Derived variables
var baseName = 'myapp${environment}'
var storageAccountName = 'st${replace(baseName, '-', '')}${uniqueString(resourceGroup().id)}'
var appServicePlanName = 'asp-${baseName}'
var functionAppName = 'func-${baseName}'
var appInsightsName = 'ai-${baseName}'
var keyVaultName = 'kv-${baseName}'

// Common tags
var commonTags = {
  environment: environment
  managedBy: 'bicep'
  project: 'azure-functions-demo'
}

// ===================================
// Storage Account (required by Functions)
// ===================================
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  tags: commonTags
  sku: {
    name: environment == 'prod' ? 'Standard_GRS' : 'Standard_LRS'  // GRS in prod
  }
  kind: 'StorageV2'
  properties: {
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    networkAcls: {
      defaultAction: 'Allow'
      bypass: 'AzureServices'
    }
  }
}

// ===================================
// Application Insights
// ===================================
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
  name: 'log-${baseName}'
  location: location
  tags: commonTags
  properties: {
    sku: {
      name: 'PerGB2018'
    }
    retentionInDays: environment == 'prod' ? 90 : 30
  }
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  tags: commonTags
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalyticsWorkspace.id
    RetentionInDays: environment == 'prod' ? 90 : 30
    DisableLocalAuth: false
  }
}

// ===================================
// App Service Plan (Hosting Plan)
// ===================================
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: appServicePlanName
  location: location
  tags: commonTags
  sku: {
    // Consumption for test, Premium for prod
    name: environment == 'prod' ? 'EP1' : 'Y1'
    tier: environment == 'prod' ? 'ElasticPremium' : 'Dynamic'
  }
  properties: {
    reserved: false  // false = Windows
    maximumElasticWorkerCount: environment == 'prod' ? 20 : 5
  }
}

// ===================================
// Function App
// ===================================
resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
  name: functionAppName
  location: location
  tags: commonTags
  kind: 'functionapp'
  identity: {
    type: 'SystemAssigned'  // Managed Identity for security
  }
  properties: {
    serverFarmId: appServicePlan.id
    httpsOnly: true
    siteConfig: {
      ftpsState: 'Disabled'
      minTlsVersion: '1.2'
      netFrameworkVersion: 'v8.0'
      use32BitWorkerProcess: false
      http20Enabled: true
      
      // Application Settings
      appSettings: [
        {
          name: 'AzureWebJobsStorage'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${az.environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${az.environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'WEBSITE_CONTENTSHARE'
          value: toLower(functionAppName)
        }
        {
          name: 'FUNCTIONS_EXTENSION_VERSION'
          value: '~4'
        }
        {
          name: 'FUNCTIONS_WORKER_RUNTIME'
          value: 'dotnet-isolated'
        }
        {
          name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
          value: appInsights.properties.ConnectionString
        }
        {
          name: 'ASPNETCORE_ENVIRONMENT'
          value: environment == 'prod' ? 'Production' : 'Development'
        }
        {
          name: 'server'  // Custom parameter for HelloYou function
          value: environment
        }
        {
          name: 'WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT'
          value: environment == 'prod' ? '20' : '5'
        }
      ]
    }
  }
}

// ===================================
// Staging Deployment Slot (prod only)
// ===================================
resource stagingSlot 'Microsoft.Web/sites/slots@2023-01-01' = if (environment == 'prod') {
  name: 'staging'
  parent: functionApp
  location: location
  tags: commonTags
  kind: 'functionapp'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: appServicePlan.id
    httpsOnly: true
    siteConfig: {
      ftpsState: 'Disabled'
      minTlsVersion: '1.2'
      netFrameworkVersion: 'v8.0'
      appSettings: [
        {
          name: 'AzureWebJobsStorage'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${az.environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'FUNCTIONS_EXTENSION_VERSION'
          value: '~4'
        }
        {
          name: 'FUNCTIONS_WORKER_RUNTIME'
          value: 'dotnet-isolated'
        }
        {
          name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
          value: appInsights.properties.ConnectionString
        }
        {
          name: 'ASPNETCORE_ENVIRONMENT'
          value: 'Staging'
        }
      ]
    }
  }
}

// ===================================
// Outputs (used by the pipeline)
// ===================================
output functionAppName string = functionApp.name
output functionAppUrl string = 'https://${functionApp.properties.defaultHostName}'
output storageAccountName string = storageAccount.name
output appInsightsConnectionString string = appInsights.properties.ConnectionString
output functionAppPrincipalId string = functionApp.identity.principalId

Decision: Shared or Separate IaC?

flowchart TD
    A{How often do\ninfrastructure changes happen?} --> B{Often at the\nsame time as code?}
    B -->|Yes| C[Single pipeline\nIaC + Code together\n✅ Simple, consistent]
    B -->|No| D[Separate pipelines\nIndependent IaC\n✅ Flexible, decoupled]
    
    E{Shared infrastructure\nbetween multiple apps?} --> F{Yes?}
    F -->|Yes| G[Separate IaC pipeline\nManages shared resources\ne.g., APIM, Key Vault, VNet]
    F -->|No| H[IaC pipeline per app\nEach team manages its own infra]
    
    style C fill:#107C10,color:#fff
    style D fill:#0078D4,color:#fff

Module 4 – Deployment with GitHub Actions {#module-4}

Complete GitHub Actions Workflow

# .github/workflows/deploy-azure-functions.yml
name: CI/CD Azure Functions

on:
  push:
    branches: 
    - main
    - 'release/**'
  pull_request:
    branches: [main]
  workflow_dispatch:  # Manual trigger possible

env:
  DOTNET_VERSION: '8.0.x'
  FUNCTION_APP_NAME: 'func-myapp-prod'
  RESOURCE_GROUP: 'rg-functions-prod'
  AZURE_REGION: 'eastus'

jobs:
  # =============================================
  # JOB 1: BUILD AND TESTS
  # =============================================
  build:
    name: '🔨 Build & Tests'
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
    
    - name: Setup .NET ${{ env.DOTNET_VERSION }}
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: ${{ env.DOTNET_VERSION }}
    
    - name: Cache NuGet packages
      uses: actions/cache@v4
      with:
        path: ~/.nuget/packages
        key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
        restore-keys: |
          ${{ runner.os }}-nuget-
    
    - name: Restore dependencies
      run: dotnet restore
    
    - name: Build
      run: dotnet build --configuration Release --no-restore
    
    - name: Test with coverage
      run: |
        dotnet test \
          --configuration Release \
          --no-build \
          --logger "trx;LogFileName=test-results.trx" \
          --collect:"XPlat Code Coverage" \
          --results-directory ./TestResults
    
    - name: Publish test results
      uses: dorny/test-reporter@v1
      if: always()
      with:
        name: 'Unit Tests'
        path: './TestResults/**/*.trx'
        reporter: 'dotnet-trx'
    
    - name: Check code coverage
      run: |
        # Verify code coverage is > 80%
        COVERAGE=$(cat ./TestResults/**/coverage.cobertura.xml | \
          grep -oP '(?<=line-rate=")[0-9.]+' | \
          head -1)
        COVERAGE_PCT=$(echo "$COVERAGE * 100" | bc -l)
        echo "Code coverage: ${COVERAGE_PCT}%"
        if (( $(echo "$COVERAGE_PCT < 80" | bc -l) )); then
          echo "❌ ERROR: Insufficient code coverage (${COVERAGE_PCT}% < 80%)"
          exit 1
        fi
        echo "✅ Code coverage OK: ${COVERAGE_PCT}%"
    
    - name: Publish Function App
      run: |
        dotnet publish Demo.FunctionApp/Demo.FunctionApp.csproj \
          --configuration Release \
          --output ./output \
          --no-build
    
    - name: Zip Function App
      run: |
        cd ./output
        zip -r ../function-app.zip .
        cd ..
    
    - name: Upload function package
      uses: actions/upload-artifact@v4
      with:
        name: function-package
        path: function-app.zip
        retention-days: 5
    
    - name: Upload Bicep templates
      uses: actions/upload-artifact@v4
      with:
        name: bicep-package
        path: Demo.Deployment/bicep/
        retention-days: 5
  
  # =============================================
  # JOB 2: TEST DEPLOYMENT
  # =============================================
  deploy-test:
    name: '🧪 Deploy to Test'
    needs: build
    runs-on: ubuntu-latest
    environment: test
    
    steps:
    - name: Download artifacts
      uses: actions/download-artifact@v4
    
    - name: Azure Login (OIDC - no secret)
      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 Infrastructure
      id: deploy-infra
      uses: azure/arm-deploy@v2
      with:
        resourceGroupName: 'rg-functions-test'
        template: bicep-package/function-app.bicep
        parameters: 'environment=test'
        deploymentName: 'deployment-${{ github.run_number }}'
    
    - name: Deploy Function App Code
      uses: Azure/functions-action@v1
      with:
        app-name: ${{ steps.deploy-infra.outputs.functionAppName }}
        package: 'function-package/function-app.zip'
    
    - name: Smoke Tests
      run: |
        FUNC_URL="https://${{ steps.deploy-infra.outputs.functionAppName }}.azurewebsites.net"
        
        echo "🔍 Test 1: Health check..."
        HEALTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$FUNC_URL/api/health")
        if [ "$HEALTH_STATUS" != "200" ]; then
          echo "❌ Health check failed: HTTP $HEALTH_STATUS"
          exit 1
        fi
        echo "✅ Health check OK"
        
        echo "🔍 Test 2: HelloWorld..."
        RESPONSE=$(curl -s "$FUNC_URL/api/hello?code=${{ secrets.FUNCTION_KEY_TEST }}")
        echo "Response: $RESPONSE"
        echo "✅ HelloWorld OK"
    
    - name: Notify Slack on failure
      if: failure()
      uses: slackapi/slack-github-action@v1
      with:
        payload: |
          {
            "text": "❌ Test deployment failed for ${{ github.repository }}",
            "blocks": [{
              "type": "section",
              "text": {
                "type": "mrkdwn",
                "text": "❌ *Test Deployment Failed*\nRepository: ${{ github.repository }}\nBranch: ${{ github.ref }}\nLink: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            }]
          }
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
  
  # =============================================
  # JOB 3: PRODUCTION DEPLOYMENT
  # =============================================
  deploy-production:
    name: '🚀 Deploy to Production'
    needs: deploy-test
    runs-on: ubuntu-latest
    environment:
      name: production
      url: 'https://func-myapp-prod.azurewebsites.net'
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    
    steps:
    - name: Download artifacts
      uses: actions/download-artifact@v4
    
    - name: Azure Login
      uses: azure/login@v2
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID_PROD }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID_PROD }}
    
    - name: Deploy Infrastructure
      id: deploy-infra-prod
      uses: azure/arm-deploy@v2
      with:
        resourceGroupName: ${{ env.RESOURCE_GROUP }}
        template: bicep-package/function-app.bicep
        parameters: 'environment=prod'
    
    - name: Deploy to Staging Slot
      uses: Azure/functions-action@v1
      with:
        app-name: ${{ steps.deploy-infra-prod.outputs.functionAppName }}
        slot-name: staging
        package: 'function-package/function-app.zip'
    
    - name: Smoke Tests on Staging Slot
      run: |
        STAGING_URL="https://${{ steps.deploy-infra-prod.outputs.functionAppName }}-staging.azurewebsites.net"
        STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$STAGING_URL/api/health")
        if [ "$STATUS" != "200" ]; then
          echo "❌ Staging tests failed: HTTP $STATUS"
          exit 1
        fi
        echo "✅ Staging validated"
    
    - name: Swap Staging to Production
      run: |
        az functionapp deployment slot swap \
          --name ${{ steps.deploy-infra-prod.outputs.functionAppName }} \
          --resource-group ${{ env.RESOURCE_GROUP }} \
          --slot staging \
          --target-slot production
        echo "✅ Swap complete - Production updated"
    
    - name: Production Health Check
      run: |
        sleep 30  # Wait for the swap to take effect
        PROD_URL="https://${{ steps.deploy-infra-prod.outputs.functionAppName }}.azurewebsites.net"
        STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PROD_URL/api/health")
        if [ "$STATUS" != "200" ]; then
          echo "❌ Production health check failed: HTTP $STATUS"
          echo "🔄 Automatic rollback to previous slot..."
          az functionapp deployment slot swap \
            --name ${{ steps.deploy-infra-prod.outputs.functionAppName }} \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --slot staging \
            --target-slot production
          exit 1
        fi
        echo "✅ Production operational!"
    
    - name: Create GitHub Release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        tag_name: 'v${{ github.run_number }}'
        release_name: 'Release ${{ github.run_number }}'
        body: |
          Automatic deployment from CI/CD pipeline
          - Build: ${{ github.run_number }}
          - Commit: ${{ github.sha }}
          - Author: ${{ github.actor }}

Module 5 – Multi-Environment Deployment Strategies {#module-5}

Branch and Environment Model

gitGraph
    commit id: "feat: Add HelloWorld"
    branch feature/add-logging
    checkout feature/add-logging
    commit id: "Add structured logging"
    commit id: "Add unit tests"
    checkout main
    merge feature/add-logging id: "Merge: Add logging"
    commit id: "Pipeline: Deploy to Test ✅"
    commit id: "Pipeline: Deploy to Prod 🚀"
    branch release/v2.0
    checkout release/v2.0
    commit id: "Version 2.0"
    checkout main
    merge release/v2.0 id: "Release 2.0 merged"

Variable Groups and Secrets in Azure DevOps

# Using Variable Groups in the pipeline
variables:
- group: 'azure-functions-common'     # Shared variables
- group: 'azure-functions-prod-secrets' # Production secrets (Key Vault linked)
- name: 'buildNumber'
  value: '$(Build.BuildNumber)'
- name: 'isMainBranch'
  value: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]

Configuring a Variable Group linked to Key Vault:

# Via Azure DevOps CLI
az devops variable-group create \
  --name "azure-functions-prod-secrets" \
  --variables \
    ApplicationInsightsKey="@keyvault:ai-connection-string" \
    SqlConnectionString="@keyvault:sql-connection-string" \
  --description "Production secrets from Key Vault"

Module 6 – Automated Testing in the Pipeline {#module-6}

Testing Pyramid for Azure Functions

flowchart TD
    subgraph "Testing Pyramid"
        E2E[End-to-End Tests\n10% - Slow - Expensive\nTests the complete flow in production] 
        INT[Integration Tests\n20% - Medium\nTest with real or emulated services]
        UNIT[Unit Tests\n70% - Fast - Low cost\nTest isolated business logic]
    end
    
    UNIT --> INT --> E2E
    
    style UNIT fill:#107C10,color:#fff
    style INT fill:#FFB900,color:#000
    style E2E fill:#D83B01,color:#fff

Unit Tests for Azure Functions (C#)

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Moq;
using System.Net;
using Xunit;

namespace Demo.FunctionApp.Tests;

public class HelloYouFunctionTests
{
    private readonly Mock<ILogger<HelloYouFunction>> _loggerMock;
    private readonly Mock<IConfiguration> _configMock;
    private readonly HelloYouFunction _function;

    public HelloYouFunctionTests()
    {
        _loggerMock = new Mock<ILogger<HelloYouFunction>>();
        _configMock = new Mock<IConfiguration>();
        
        // Configure the configuration mock
        _configMock.Setup(c => c["server"]).Returns("test-server");
        
        _function = new HelloYouFunction(_loggerMock.Object, _configMock.Object);
    }

    [Fact]
    public async Task Run_WithName_ReturnsPersonalizedMessage()
    {
        // Arrange
        var context = CreateFunctionContext();
        var req = CreateHttpRequest(context, queryParams: new Dictionary<string, string>
        {
            ["name"] = "Alice"
        });

        // Act
        var response = await _function.Run(req);

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        
        response.Body.Seek(0, SeekOrigin.Begin);
        using var reader = new StreamReader(response.Body);
        var body = await reader.ReadToEndAsync();
        
        Assert.Contains("Alice", body);
        Assert.Contains("test-server", body);
    }

    [Fact]
    public async Task Run_WithoutName_ReturnsAnonymousMessage()
    {
        // Arrange
        var context = CreateFunctionContext();
        var req = CreateHttpRequest(context, queryParams: new Dictionary<string, string>());

        // Act
        var response = await _function.Run(req);

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        
        response.Body.Seek(0, SeekOrigin.Begin);
        using var reader = new StreamReader(response.Body);
        var body = await reader.ReadToEndAsync();
        
        Assert.Contains("anonymous", body.ToLower());
    }

    [Theory]
    [InlineData("Alice", "Hello, Alice!")]
    [InlineData("Bob", "Hello, Bob!")]
    [InlineData("", null)]
    public async Task Run_WithVariousNames_ReturnsCorrectMessage(string name, string? expectedContains)
    {
        // Arrange
        var context = CreateFunctionContext();
        var queryParams = string.IsNullOrEmpty(name) 
            ? new Dictionary<string, string>() 
            : new Dictionary<string, string> { ["name"] = name };
        var req = CreateHttpRequest(context, queryParams);

        // Act
        var response = await _function.Run(req);

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        
        if (expectedContains is not null)
        {
            response.Body.Seek(0, SeekOrigin.Begin);
            using var reader = new StreamReader(response.Body);
            var body = await reader.ReadToEndAsync();
            Assert.Contains(expectedContains, body);
        }
    }

    private static FunctionContext CreateFunctionContext()
    {
        var context = new Mock<FunctionContext>();
        var serviceProvider = new Mock<IServiceProvider>();
        context.Setup(c => c.InstanceServices).Returns(serviceProvider.Object);
        return context.Object;
    }

    private static HttpRequestData CreateHttpRequest(
        FunctionContext context, 
        Dictionary<string, string> queryParams)
    {
        var request = new Mock<HttpRequestData>(context);
        
        var query = System.Web.HttpUtility.ParseQueryString(string.Empty);
        foreach (var (key, value) in queryParams)
            query[key] = value;
        
        request.Setup(r => r.Query).Returns(
            new System.Collections.Specialized.NameValueCollection { query });
        request.Setup(r => r.Body).Returns(new MemoryStream());
        
        var responseObject = new TestHttpResponseData(context);
        request.Setup(r => r.CreateResponse(It.IsAny<HttpStatusCode>()))
               .Returns((HttpStatusCode code) => 
               {
                   responseObject.StatusCode = code;
                   return responseObject;
               });
        
        return request.Object;
    }
}

Module 7 – Secrets and Secure Configuration {#module-7}

Secrets Hierarchy in Pipelines

flowchart TD
    subgraph "NEVER do this"
        BAD1[Secrets in source code] 
        BAD2[Secrets in pipeline variables\nin plain text]
        BAD3[Secrets in logs]
    end
    
    subgraph "Best practice"
        GOOD1[Azure Key Vault\n✅ Source of truth for secrets]
        GOOD2[Variable Groups linked to Key Vault\n✅ Secure access in pipelines]
        GOOD3[GitHub Secrets / Azure DevOps Secrets\n✅ Encrypted, masked in logs]
        GOOD4[Managed Identity\n✅ No secrets at all]
    end
    
    GOOD1 --> GOOD2 --> Pipeline[CI/CD Pipeline]
    GOOD3 --> Pipeline
    GOOD4 --> Pipeline
    
    style BAD1 fill:#D83B01,color:#fff
    style BAD2 fill:#D83B01,color:#fff
    style BAD3 fill:#D83B01,color:#fff
    style GOOD4 fill:#107C10,color:#fff
# Configure OIDC (OpenID Connect) for GitHub Actions
# Allows GitHub Actions to authenticate to Azure WITHOUT secrets

# 1. Create an App Registration in Entra ID
az ad app create --display-name "github-actions-functions"

APP_ID=$(az ad app show --id "github-actions-functions" --query appId -o tsv)

# 2. Create the Service Principal
az ad sp create --id $APP_ID

SP_OBJECT_ID=$(az ad sp show --id $APP_ID --query id -o tsv)

# 3. Create the Federated Identity Credential (OIDC)
az ad app federated-credential create \
  --id $APP_ID \
  --parameters '{
    "name": "github-actions-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:my-org/my-functions:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# 4. Assign Azure RBAC permissions
az role assignment create \
  --assignee-object-id $SP_OBJECT_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope "/subscriptions/{sub-id}/resourceGroups/rg-functions-prod"

echo "Configure these GitHub Secrets:"
echo "AZURE_CLIENT_ID: $APP_ID"
echo "AZURE_TENANT_ID: $(az account show --query tenantId -o tsv)"
echo "AZURE_SUBSCRIPTION_ID: $(az account show --query id -o tsv)"

Module 8 – Rollback and Incident Management {#module-8}

Rollback Strategy with Deployment Slots

sequenceDiagram
    participant P as Pipeline
    participant S as Staging Slot
    participant PR as Production
    participant M as Monitoring

    P->>S: Deploy v2.0 to staging
    P->>S: Staging smoke tests ✅
    P->>PR: Swap staging → production
    PR->>M: Production active on v2.0
    M->>P: ⚠️ Alerts: error rate 5% (threshold: 1%)
    P->>P: Auto-rollback triggered
    P->>PR: Swap production → staging (back to v1.9)
    PR->>M: Production back to v1.9
    M->>P: ✅ Error rate back to 0.1%
# Automatic rollback script
#!/bin/bash
set -e

FUNC_APP="func-myapp-prod"
RG="rg-functions-prod"
ERROR_THRESHOLD=5  # Acceptable % errors

echo "Checking health after deployment..."

# Wait 5 minutes for metrics to stabilize
sleep 300

# Retrieve the error rate via Azure Monitor
ERROR_RATE=$(az monitor metrics list \
  --resource "/subscriptions/{sub}/resourceGroups/$RG/providers/Microsoft.Web/sites/$FUNC_APP" \
  --metric "Http5xx" \
  --interval PT5M \
  --aggregation Average \
  --query "value[0].timeseries[0].data[-1].average" \
  -o tsv)

echo "Current error rate: $ERROR_RATE%"

if (( $(echo "$ERROR_RATE > $ERROR_THRESHOLD" | bc -l) )); then
  echo "❌ Error rate too high ($ERROR_RATE% > $ERROR_THRESHOLD%)"
  echo "🔄 Starting rollback..."
  
  az functionapp deployment slot swap \
    --name $FUNC_APP \
    --resource-group $RG \
    --slot staging \
    --target-slot production
  
  echo "✅ Rollback completed to previous version"
  
  # Notify the team
  curl -X POST "$SLACK_WEBHOOK" \
    -H 'Content-type: application/json' \
    --data "{\"text\":\"🔄 Automatic rollback executed for $FUNC_APP\\nError rate: $ERROR_RATE%\"}"
  
  exit 1
else
  echo "✅ Deployment validated! Error rate: $ERROR_RATE%"
fi

Module 9 – DevOps Best Practices {#module-9}

CI/CD Pipeline Checklist for Azure Functions

PhaseCheckPriority
Code.gitignore excludes local.settings.json🔴 Critical
CodeNo secrets in plain text in code🔴 Critical
BuildReproducible build (—no-restore, fixed versions)🟡 Important
TestsCoverage > 80%🟡 Important
TestsUnit tests < 30 seconds🟢 Nice to have
SecurityOIDC / Workload Identity (no pipeline secrets)🔴 Critical
InfrastructureIaC (Bicep/Terraform) for all infrastructure🟡 Important
InfrastructureParameters per environment (test/prod)🟡 Important
DeploymentDeployment Slots for zero-downtime🟡 Important
DeploymentPost-deployment smoke tests🔴 Critical
DeploymentAutomatic rollback if smoke tests fail🟡 Important
MonitoringAlerts configured on key metrics🟡 Important
DocumentationREADME with deployment instructions🟢 Nice to have

Deployment Slots

flowchart LR
    subgraph FunctionApp["Azure Function App"]
        P[Slot: production\nv1.0]
        S[Slot: staging\nv2.0]
    end
    CI[CI/CD Pipeline] -->|Deploys| S
    S -->|Atomic swap| P
    S -.->|Rollback\nreverse swap| P

Note: Deployment Slots require a Standard, Premium or Dedicated plan — not available on the Consumption plan.

# Create a Staging slot
az functionapp deployment slot create \
  --name my-functions-app \
  --resource-group my-rg \
  --slot staging

# Verify existing slots
az functionapp deployment slot list \
  --name my-functions-app \
  --resource-group my-rg \
  --output table

# Swap with preview (two-phase)
# Phase 1: Apply production slot settings in staging (without swapping traffic)
az webapp deployment slot swap \
  --resource-group my-rg \
  --name my-functions-app \
  --slot staging \
  --target-slot production \
  --action applySlotConfig

# Phase 2: Complete the swap (or cancel)
az webapp deployment slot swap \
  --resource-group my-rg \
  --name my-functions-app \
  --slot staging \
  --target-slot production \
  --action swap  # or --action resetSlotConfig to cancel

Glossary {#glossary}

TermDefinition
AgentVirtual machine (cloud or self-hosted) executing pipeline jobs
ArtifactFile or package produced by the pipeline (ZIP, Docker image)
BicepDeclarative DSL language for Azure Resource Manager (IaC)
CIContinuous Integration - automatic integration of code changes
ClickOpsManual infrastructure creation via the Azure portal (to avoid)
CDContinuous Delivery/Deployment - automatic delivery to environments
Deployment SlotParallel environment of a Function App (e.g., staging)
EnvironmentEnvironment configuration in Azure DevOps/GitHub with approvals
IaCInfrastructure as Code - infrastructure described in versioned files
OIDCOpenID Connect - secretless authentication protocol for pipelines
PipelineYAML definition automating build, test and deployment
Service ConnectionSecure connection from Azure DevOps to Azure
Slot SwapAtomic swap of two slots (staging ↔ production)
Smoke TestMinimal post-deployment test verifying the application starts
StageLogical grouping of steps in a pipeline (Build, Test, Production)
Variable GroupGroup of variables shared between multiple pipelines
Workload IdentityOIDC federated authentication for GitHub Actions → Azure (no secret)
YAML PipelinePipeline defined in YAML code versioned in Git

Search Terms

ci/cd · integrating · azure · functions · pipelines · serverless · microsoft · deployment · devops · pipeline · secrets · actions · bicep · configuration · github · infrastructure · rollback · secure · slots · template · testing

Interested in this course?

Contact us to book it or get a custom training plan for your team.