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
- Why CI/CD for Azure Functions
- Deployment with Azure DevOps
- Infrastructure as Code with Bicep
- Deployment with GitHub Actions
- Multi-Environment Deployment Strategies
- Automated Testing in the Pipeline
- Secrets and Secure Configuration
- Rollback and Incident Management
- DevOps Best Practices
- 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
| Benefit | Description | Measurable Impact |
|---|---|---|
| Reduced time-to-market | Deploy as soon as a feature is complete | From weeks to hours |
| Fewer errors | Automation = eliminate manual errors | -80% post-deployment incidents |
| Easy rollback | Re-run the pipeline for the previous version | Rollback in < 10 minutes |
| Cost-effective | One-time investment, reusable | ROI > 1000% over 1 year |
| Reproducibility | Same code → same result every time | 100% consistency |
| Transparency | Logs: who deployed what and when | Complete audit trail |
| Automated tests | Regression detected before production | Guaranteed quality |
| Infrastructure as Code | Infrastructure versioned like code | Identical 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
| Tool | Description |
|---|---|
| Azure DevOps Pipelines | YAML-based, integrated with Azure, service connections |
| GitHub Actions | YAML workflows in GitHub, actions marketplace |
Module 2 – Deployment with Azure DevOps {#module-2}
Azure DevOps Concepts
| Concept | Description |
|---|---|
| Organization | Top level - groups all company projects |
| Project | Workspace with repos, pipelines, boards |
| Repository | Git repository hosted in Azure DevOps |
| Pipeline | YAML or classic definition of the CI/CD workflow |
| Stage | Logical grouping of steps (Build, Test, Production) |
| Job | Set of steps running on an agent |
| Step | Individual task (script, predefined task) |
| Agent | Virtual machine executing jobs |
| Service Connection | Secure connection to Azure (credential store) |
| Artifact | File produced by the pipeline (ZIP, Docker image) |
| Variable Group | Group 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
| Phase | Check | Priority |
|---|---|---|
| Code | .gitignore excludes local.settings.json | 🔴 Critical |
| Code | No secrets in plain text in code | 🔴 Critical |
| Build | Reproducible build (—no-restore, fixed versions) | 🟡 Important |
| Tests | Coverage > 80% | 🟡 Important |
| Tests | Unit tests < 30 seconds | 🟢 Nice to have |
| Security | OIDC / Workload Identity (no pipeline secrets) | 🔴 Critical |
| Infrastructure | IaC (Bicep/Terraform) for all infrastructure | 🟡 Important |
| Infrastructure | Parameters per environment (test/prod) | 🟡 Important |
| Deployment | Deployment Slots for zero-downtime | 🟡 Important |
| Deployment | Post-deployment smoke tests | 🔴 Critical |
| Deployment | Automatic rollback if smoke tests fail | 🟡 Important |
| Monitoring | Alerts configured on key metrics | 🟡 Important |
| Documentation | README 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}
| Term | Definition |
|---|---|
| Agent | Virtual machine (cloud or self-hosted) executing pipeline jobs |
| Artifact | File or package produced by the pipeline (ZIP, Docker image) |
| Bicep | Declarative DSL language for Azure Resource Manager (IaC) |
| CI | Continuous Integration - automatic integration of code changes |
| ClickOps | Manual infrastructure creation via the Azure portal (to avoid) |
| CD | Continuous Delivery/Deployment - automatic delivery to environments |
| Deployment Slot | Parallel environment of a Function App (e.g., staging) |
| Environment | Environment configuration in Azure DevOps/GitHub with approvals |
| IaC | Infrastructure as Code - infrastructure described in versioned files |
| OIDC | OpenID Connect - secretless authentication protocol for pipelines |
| Pipeline | YAML definition automating build, test and deployment |
| Service Connection | Secure connection from Azure DevOps to Azure |
| Slot Swap | Atomic swap of two slots (staging ↔ production) |
| Smoke Test | Minimal post-deployment test verifying the application starts |
| Stage | Logical grouping of steps in a pipeline (Build, Test, Production) |
| Variable Group | Group of variables shared between multiple pipelines |
| Workload Identity | OIDC federated authentication for GitHub Actions → Azure (no secret) |
| YAML Pipeline | Pipeline 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