GitHub Actions CI/CD for Azure App Service: Step-by-Step


Executive Snapshot

Category Recommendation
Auth Method Federated Identity (OIDC) — avoid long-lived secrets
Deployment Method azure/webapps-deploy action with publish profile fallback
Runtime Coverage Node.js, Python, .NET — patterns apply universally
Slot Strategy Always deploy to staging slot, then swap to production
Secret Storage GitHub Encrypted Secrets + Azure Key Vault for app secrets
Branch Strategy main → production, develop → staging via environment protection rules
Estimated Setup Time 25–40 minutes for a working pipeline end-to-end

TL;DR

  • GitHub Actions can deploy to Azure App Service in under 30 minutes using Microsoft's first-party actions and either OIDC federated credentials or a publish profile.
  • OIDC authentication is the modern best practice — it eliminates stored credentials and rotates automatically; publish profiles are simpler but carry higher blast radius if leaked.
  • Deployment slots + slot swaps give you near-zero-downtime deployments and an instant rollback lever — always use them for production workloads.
  • Environment protection rules in GitHub enforce manual approval gates before production deploys, giving you a lightweight change-control layer at zero extra cost.
  • A single reusable workflow file can serve multiple runtimes by parameterising the build steps, keeping your .github/workflows/ directory maintainable.

Introduction

Shipping code to Azure App Service used to mean wrestling with FTP credentials, Kudu deployment scripts, or Azure DevOps pipelines bolted awkwardly onto a GitHub repository. GitHub Actions changed that calculus entirely. Today, the entire CI/CD loop — lint, test, build, deploy — lives in a single YAML file alongside your application code, version-controlled, reviewable, and reproducible.

flowchart LR
  Push["git push"] --> WF["GitHub Actions workflow"]
  WF --> Build["Build & test"]
  Build --> Pkg["Package artifact"]
  Pkg --> Deploy["Deploy to staging slot"]
  Deploy --> Swap{"Validate"}
  Swap -->|swap| Prod["Production slot"]
Figure: A GitHub Actions pipeline deploying to Azure App Service with a slot swap.

But "works on the tutorial" and "works in production" are two different things. Most getting-started guides stop at pushing a Hello World app to a single slot with a publish profile stored as a plain-text secret. That approach will embarrass you the moment a secret leaks, a bad deploy takes down production, or a compliance auditor asks for your change-approval evidence.

This guide covers the full production-grade path: federated OIDC authentication, staging-slot-to-production swap strategy, environment protection rules for approval gates, and troubleshooting the errors you'll actually hit. By the end, you'll have a battle-tested GitHub Actions Azure App Service pipeline you can drop into any project.


Prerequisites

  • An Azure subscription with Contributor or Owner access on the target resource group
  • An Azure App Service plan (Free/Basic tier works for the tutorial; Standard or higher required for deployment slots)
  • A GitHub repository with your application source code
  • Azure CLI 2.50+ installed locally (az --version to verify)
  • GitHub CLI (gh) — optional but speeds up secret creation
  • Familiarity with YAML syntax and basic Azure resource concepts (resource groups, app services)
  • Node.js 20 LTS, Python 3.12, or .NET 8 source app to deploy (examples cover all three; adapt as needed)

1. Provision the Azure App Service

If you already have an App Service, skip to Section 2. Otherwise, spin up a minimal environment with the Azure CLI:

# Variables — adjust to your project
RESOURCE_GROUP="rg-myapp-prod"
LOCATION="eastus"
APP_PLAN="plan-myapp-prod"
APP_NAME="app-myapp-prod-$(openssl rand -hex 4)"   # unique name required

# Create resource group
az group create \
  --name "$RESOURCE_GROUP" \
  --location "$LOCATION"

# Create App Service plan (Standard S1 enables deployment slots)
az appservice plan create \
  --name "$APP_PLAN" \
  --resource-group "$RESOURCE_GROUP" \
  --sku S1 \
  --is-linux

# Create the web app — change --runtime to match your stack
az webapp create \
  --name "$APP_NAME" \
  --resource-group "$RESOURCE_GROUP" \
  --plan "$APP_PLAN" \
  --runtime "NODE:20-lts"

# Create a staging slot
az webapp deployment slot create \
  --name "$APP_NAME" \
  --resource-group "$RESOURCE_GROUP" \
  --slot "staging"

echo "App Service name: $APP_NAME"

Note: Save $APP_NAME — you'll need it in several places. If you're using PowerShell, see the equivalent block in Section 6.


2. Configure OIDC Federated Identity (Recommended Auth Method)

OIDC lets GitHub Actions authenticate to Azure without storing any credential. Azure trusts tokens issued by GitHub's OIDC provider directly.

2a. Create an Azure AD App Registration and Service Principal

# Grab your subscription and tenant IDs
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)

# Create the app registration
APP_REG_NAME="sp-github-myapp"
az ad app create --display-name "$APP_REG_NAME"
APP_ID=$(az ad app list --display-name "$APP_REG_NAME" --query "[0].appId" -o tsv)

# 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)

# Assign Contributor on the resource group
az role assignment create \
  --assignee "$SP_OBJECT_ID" \
  --role "Contributor" \
  --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP"

echo "CLIENT_ID (APP_ID): $APP_ID"
echo "TENANT_ID: $TENANT_ID"
echo "SUBSCRIPTION_ID: $SUBSCRIPTION_ID"

2b. Add a Federated Credential

# Replace ORG and REPO with your GitHub org/username and repository name
GITHUB_ORG="my-org"
GITHUB_REPO="my-app"

# Federated credential for the 'main' branch
az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters "{
    \"name\": \"github-main\",
    \"issuer\": \"https://token.actions.githubusercontent.com\",
    \"subject\": \"repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/main\",
    \"audiences\": [\"api://AzureADTokenExchange\"]
  }"

# Federated credential for the staging environment (if using GitHub Environments)
az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters "{
    \"name\": \"github-env-production\",
    \"issuer\": \"https://token.actions.githubusercontent.com\",
    \"subject\": \"repo:${GITHUB_ORG}/${GITHUB_REPO}:environment:production\",
    \"audiences\": [\"api://AzureADTokenExchange\"]
  }"

2c. Store Credentials as GitHub Secrets

# Using GitHub CLI
gh secret set AZURE_CLIENT_ID     --body "$APP_ID"        --repo "$GITHUB_ORG/$GITHUB_REPO"
gh secret set AZURE_TENANT_ID     --body "$TENANT_ID"     --repo "$GITHUB_ORG/$GITHUB_REPO"
gh secret set AZURE_SUBSCRIPTION_ID --body "$SUBSCRIPTION_ID" --repo "$GITHUB_ORG/$GITHUB_REPO"

Alternatively, go to Repository → Settings → Secrets and variables → Actions → New repository secret and add each value manually.


3. Configure GitHub Environments and Protection Rules

GitHub Environments add an approval gate so no one can push directly to production without a human sign-off.

  1. Navigate to Repository → Settings → Environments → New environment
  2. Create two environments: staging and production
  3. On the production environment, enable Required reviewers and add your team or yourself
  4. Optionally set a Wait timer (e.g., 5 minutes) to allow smoke tests to surface before promotion

This configuration creates an auditable approval trail — every production deploy records who approved it and when.


4. Write the GitHub Actions Workflow

Now for the main event. Create .github/workflows/deploy-azure.yml in your repository:

# .github/workflows/deploy-azure.yml
name: CI/CD → Azure App Service

on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - main

permissions:
  id-token: write   # Required for OIDC
  contents: read

env:
  AZURE_WEBAPP_NAME: app-myapp-prod-XXXX   # Replace with your app name
  AZURE_RESOURCE_GROUP: rg-myapp-prod
  NODE_VERSION: "20.x"

jobs:
  # ─────────────────────────────────────────────
  # JOB 1: Build & Test
  # ─────────────────────────────────────────────
  build:
    name: Build and Test
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run lint
        run: npm run lint --if-present

      - name: Run tests
        run: npm test --if-present

      - name: Build application
        run: npm run build --if-present

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: app-build
          path: |
            dist/
            package.json
            package-lock.json
          retention-days: 1

  # ─────────────────────────────────────────────
  # JOB 2: Deploy to Staging Slot
  # ─────────────────────────────────────────────
  deploy-staging:
    name: Deploy → Staging Slot
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
    environment:
      name: staging
      url: https://${{ env.AZURE_WEBAPP_NAME }}-staging.azurewebsites.net

    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: app-build
          path: ./release

      - name: Login to Azure (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 staging slot
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          slot-name: staging
          package: ./release

      - name: Run smoke test against staging
        run: |
          STAGING_URL="https://${{ env.AZURE_WEBAPP_NAME }}-staging.azurewebsites.net"
          HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$STAGING_URL/health")
          if [ "$HTTP_STATUS" != "200" ]; then
            echo "Smoke test FAILED — HTTP $HTTP_STATUS from $STAGING_URL/health"
            exit 1
          fi
          echo "Smoke test passed — HTTP $HTTP_STATUS"

  # ─────────────────────────────────────────────
  # JOB 3: Promote to Production (slot swap)
  # ─────────────────────────────────────────────
  deploy-production:
    name: Swap Staging → Production
    runs-on: ubuntu-latest
    needs: deploy-staging
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net

    steps:
      - name: Login to Azure (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: Swap staging slot to production
        uses: azure/CLI@v2
        with:
          inlineScript: |
            az webapp deployment slot swap \
              --name ${{ env.AZURE_WEBAPP_NAME }} \
              --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \
              --slot staging \
              --target-slot production

      - name: Verify production health
        run: |
          PROD_URL="https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net"
          for i in 1 2 3; do
            HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PROD_URL/health")
            if [ "$HTTP_STATUS" == "200" ]; then
              echo "Production health check passed (attempt $i)"
              exit 0
            fi
            echo "Attempt $i: HTTP $HTTP_STATUS — retrying in 10s..."
            sleep 10
          done
          echo "Production health check FAILED after 3 attempts"
          exit 1

5. Runtime Variations

Python (Django / FastAPI)

Replace the build job's Node.js steps with:

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

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: python -m pytest tests/ -v --tb=short

      - name: Package application
        run: |
          mkdir -p release
          cp -r . release/
          # Exclude dev artifacts
          rm -rf release/.git release/tests release/__pycache__

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: app-build
          path: release/

Also update the az webapp create runtime flag: --runtime "PYTHON:3.12".

.NET 8

      - name: Set up .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "8.0.x"

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: dotnet test --no-build --verbosity normal

      - name: Publish
        run: dotnet publish --configuration Release --output ./release

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: app-build
          path: release/

6. PowerShell Equivalent for Windows Teams

For teams that manage Azure resources from Windows workstations:

# Requires Az PowerShell module: Install-Module -Name Az -Scope CurrentUser
Import-Module Az

$resourceGroup = "rg-myapp-prod"
$location      = "eastus"
$appPlan       = "plan-myapp-prod"
$appName       = "app-myapp-prod-$(Get-Random -Maximum 9999)"

Connect-AzAccount

# Create Resource Group
New-AzResourceGroup -Name $resourceGroup -Location $location

# Create App Service Plan (Standard for slots)
New-AzAppServicePlan `
  -ResourceGroupName $resourceGroup `
  -Name $appPlan `
  -Location $location `
  -Tier "Standard" `
  -NumberofWorkers 1 `
  -WorkerSize "Small" `
  -Linux

# Create Web App
New-AzWebApp `
  -ResourceGroupName $resourceGroup `
  -Name $appName `
  -Location $location `
  -AppServicePlan $appPlan

# Create staging slot
New-AzWebAppSlot `
  -ResourceGroupName $resourceGroup `
  -Name $appName `
  -Slot "staging"

Write-Host "App Service created: $appName" -ForegroundColor Green

7. Pitfalls to Avoid

Using Publish Profiles as Your Only Auth Method

Publish profiles are base64-encoded credentials with no expiry. One leaked secret in a public repo history means anyone can deploy arbitrary code to your app. Use OIDC for new pipelines; if you must use publish profiles in a legacy context, scope them to a slot and rotate after every team offboarding.

Deploying Directly to the Production Slot

Deploying directly causes a cold-start gap and gives you no safe rollback path. Always deploy to staging first, run smoke tests, then swap. The swap itself is near-instantaneous at the Azure load-balancer layer.

Not Pinning Action Versions

Using azure/login@main or actions/checkout@latest means a compromised upstream action can silently alter your pipeline. Always pin to a specific major version tag (e.g., @v4) or, for high-security environments, pin to a commit SHA.

Ignoring id-token: write Permission

OIDC will silently fail with a cryptic Error: Could not fetch the OIDC token message if you forget permissions: id-token: write at the workflow or job level. Add it explicitly — don't rely on repo defaults.

Storing Application Secrets in GitHub Secrets

GitHub Secrets are fine for CI/CD credentials (client IDs, subscription IDs). They are not the right store for database connection strings, API keys, or anything your application reads at runtime. Use Azure App Configuration or Azure Key Vault with a managed identity for runtime secrets.

Skipping Smoke Tests Before Swap

Without a smoke test on the staging slot, a bad deploy swaps straight into production. Even a single curl /health check is better than nothing; ideally run a lightweight integration test suite against the staging URL before promoting.


Troubleshooting

Symptom Likely Cause Fix
AADSTS70011: The provided request must include a 'scope' input Federated credential subject mismatch Verify the subject in the federated credential exactly matches the branch/environment triggering the workflow
Error: Could not fetch the OIDC token Missing id-token: write permission Add permissions: id-token: write to your workflow or job block
az webapp deployment slot swap fails with 409 Conflict Previous swap still in progress Wait 2–3 minutes and retry; add --verbose flag for swap state details
Artifact download fails: no artifact found Build job didn't complete or artifact name mismatch Check build job logs; ensure upload-artifact and download-artifact use identical name values
App returns 503 immediately after swap App hasn't warmed up Enable Application Initialization in App Service config; add a /health warm-up path
Smoke test URL times out Staging slot is stopped or app crashed Check App Service logs: az webapp log tail --name $APP_NAME --slot staging
Package deployment using ZIP Deploy fails with 413 Artifact too large Exclude node_modules from the artifact; let App Service run npm install via Oryx build
GitHub Environment not triggering approval Environment name mismatch Environment name in workflow YAML must exactly match the name in GitHub Settings (case-sensitive)

Key Takeaways

  • OIDC federated credentials are the production-grade authentication method for GitHub Actions Azure App Service pipelines — no stored secrets, no expiry management.
  • A three-job pipeline (build → staging deploy → production swap) is the minimum viable production pattern; it costs nothing extra on Azure and provides rollback at no additional complexity.
  • GitHub Environments with required reviewers deliver a lightweight, auditable change-approval process built into the same YAML file as your build steps.
  • Pin all GitHub Actions to versioned tags to protect your supply chain; this is a one-character change that eliminates an entire class of risk.
  • Runtime swap is a platform-level atomic operation — users see no downtime during a successful staging-to-production promotion.
  • Smoke tests between deploy and swap are non-negotiable for production workloads; even a trivial health endpoint check will catch the majority of bad deploys before they reach users.
  • Application secrets belong in Azure Key Vault, not in GitHub Secrets — use managed identity or the App Service Key Vault reference feature to pull them at runtime.

Next Steps

  1. Add Azure Key Vault references to your App Service configuration so no application secret ever touches an environment variable in plaintext — Microsoft Docs: Use Key Vault references
  2. Integrate dependency scanning into your build job using actions/dependency-review-action or Dependabot alerts to catch vulnerable packages before they deploy
  3. Set up Azure Monitor alerts on 5xx error rate and response time tied to your App Service — wire them to a Logic App or Teams webhook so the team knows within minutes if a swap causes degradation
  4. Explore GitHub Actions reusable workflows (workflow_call trigger) to DRY up pipelines across multiple microservices that all target Azure App Service
  5. Consider Azure Deployment Environments if your team needs on-demand ephemeral environments per pull request — it integrates directly with GitHub Actions using the same OIDC pattern shown here

Related Articles


Last reviewed: 2026-06-17 | Tested against: GitHub Actions runner ubuntu-24.04, Azure CLI 2.61, azure/login@v2, azure/webapps-deploy@v3

Thorsteinn Halldorsson Senior Cloud Engineer

Senior Cloud Engineer with 25+ years of hands-on experience across the datacenter-to-cloud stack: fiber SAN and disk storage, IBM/Lenovo blade and Dell/HP/Lenovo servers, Hyper-V and VMware clusters, and SQL and Remote Desktop Services (RDS) clusters. Deep in the Microsoft platform — Active Directory, PKI/certificate services, SQL, Power BI, Dynamics 365 Business Central (NAV) and AX (Axapta), Microsoft 365, Entra, and Intune — with a focus on Azure operations, FinOps, and applying AI tools like GitHub Copilot and Claude in real workflows. Writes practical, no-nonsense guides for IT professionals who need to ship real solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *