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"]
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 --versionto 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.
- Navigate to Repository → Settings → Environments → New environment
- Create two environments:
stagingandproduction - On the production environment, enable Required reviewers and add your team or yourself
- 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
- 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
- Integrate dependency scanning into your build job using
actions/dependency-review-actionor Dependabot alerts to catch vulnerable packages before they deploy - 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
- Explore GitHub Actions reusable workflows (
workflow_calltrigger) to DRY up pipelines across multiple microservices that all target Azure App Service - 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
- Azure Container Apps CI/CD with GitHub Actions: Blue-Green Deployments — Apply the same slot-swap mental model to containerised workloads
- GitHub Actions Security Hardening: OIDC, Permissions, and Supply Chain — Deep-dive into pinning, least-privilege permissions, and secret scanning
- Infrastructure as Code for Azure App Service with Bicep and GitHub Actions — Manage your App Service configuration alongside your application code for fully reproducible environments
Last reviewed: 2026-06-17 | Tested against: GitHub Actions runner ubuntu-24.04, Azure CLI 2.61, azure/login@v2, azure/webapps-deploy@v3
Leave a Reply