Deploying an Azure Static Web App Using GitHub Actions
This post captures a clean, repeatable way to provision and deploy an Azure Static Web App (SWA) with GitHub Actions. The goal was simple: make the pipeline idempotent and easy to re-run in a new subscription without hand edits or stale secrets.
What the pipeline does​
High-level flow:
- Detect changes to decide whether infra or app deploy should run.
- Provision SWA infra with Bicep.
- Upsert Cloudflare DNS for the custom domain.
- Validate the custom domain in Azure.
- Fetch the SWA deployment token at runtime.
- Build and deploy site content.
Workflow and scripts​
Below are the workflow and script excerpts with sensitive values kept as environment variables. Replace names and domains to suit your setup.
name: Azure Static Web Apps CI/CD
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
env:
LOCATION: Australia East
SWA_NAME: digital-reflections
SWA_RESOURCE_GROUP: digital-reflections
CUSTOM_DOMAIN: www.digital-reflections.com
jobs:
changes:
runs-on: ubuntu-latest
name: Detect Changes
outputs:
infra: ${{ steps.filter.outputs.infra }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
infra:
- 'infra/**'
app:
- '**'
- '!infra/**'
deploy_swa_job:
needs: changes
if: needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
name: Provision SWA Infra + DNS
permissions:
contents: read
id-token: write
env:
GH_SWA_TOKEN: ${{ secrets.GH_SWA_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: true
lfs: false
persist-credentials: false
# --- Validate ---
- name: Validate Bicep Template
uses: azure/powershell@v2
with:
inlineScript: |
$Inputs = @{
Location = '${{ env.LOCATION }}'
TemplateParameterFile = "${{ github.workspace }}/infra/parameters/main.bicepparam"
}
Test-AzSubscriptionDeployment @Inputs -Verbose
azPSVersion: "latest"
# --- Deploy infra ---
- name: Deploy Bicep Template
id: deploy_bicep
uses: azure/powershell@v2
with:
inlineScript: |
$deploymentName = "swa-$env:GITHUB_RUN_ID"
$Inputs = @{
Location = '${{ env.LOCATION }}'
TemplateParameterFile = "${{ github.workspace }}/infra/parameters/main.bicepparam"
}
New-AzSubscriptionDeployment -Name $deploymentName @Inputs -Verbose | Out-Null
$deploymentOutput = Get-AzSubscriptionDeployment -Name $deploymentName
$staticSiteName = $deploymentOutput.Outputs.staticSiteName.value
$staticSiteUrl = $deploymentOutput.Outputs.staticSiteUrl.value
$staticSiteResourceGroupName = $deploymentOutput.Outputs.staticSiteResourceGroupName.value
Add-Content -Path $env:GITHUB_ENV -Value "AZ_SWA_NAME=$staticSiteName"
Add-Content -Path $env:GITHUB_ENV -Value "AZ_SWA_DEFAULTHOSTNAME=$staticSiteUrl"
Add-Content -Path $env:GITHUB_ENV -Value "AZ_SWA_RESOURCEGROUPNAME=$staticSiteResourceGroupName"
azPSVersion: "latest"
# --- DNS ---
- name: Resolve SWA default hostname
if: github.event_name != 'pull_request'
run: |
hostname=$(az staticwebapp show \
--name "${AZ_SWA_NAME}" \
--resource-group "${AZ_SWA_RESOURCEGROUPNAME}" \
--query defaultHostname \
--output tsv)
echo "AZ_SWA_DEFAULTHOSTNAME=${hostname}" >> "${GITHUB_ENV}"
- name: Update Cloudflare CNAME
if: github.event_name != 'pull_request'
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
RECORD_NAME: ${{ env.CUSTOM_DOMAIN }}
RECORD_CONTENT: ${{ env.AZ_SWA_DEFAULTHOSTNAME }}
run: |
./scripts/cloudflare-upsert-cname.sh
- name: Wait for DNS propagation
if: github.event_name != 'pull_request'
env:
RECORD_NAME: ${{ env.CUSTOM_DOMAIN }}
RECORD_CONTENT: ${{ env.AZ_SWA_DEFAULTHOSTNAME }}
run: |
target="${RECORD_CONTENT%.}"
for i in {1..20}; do
current=$(dig +short "${RECORD_NAME}" | head -n 1 | sed 's/\.$//')
if [[ "${current}" == "${target}" ]]; then
exit 0
fi
sleep 15
done
exit 1
# --- Custom domain ---
- name: Add custom domain to Static Web App
if: github.event_name != 'pull_request'
run: |
az staticwebapp hostname set \
--name "${AZ_SWA_NAME}" \
--resource-group "${AZ_SWA_RESOURCEGROUPNAME}" \
--hostname "${CUSTOM_DOMAIN}"
build_and_deploy_job:
if: >
${{
always() &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.action != 'closed')) &&
(needs.changes.outputs.app == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch') &&
needs.deploy_swa_job.result != 'failure'
}}
needs: [changes, deploy_swa_job]
runs-on: ubuntu-latest
name: Build & Deploy Site
env:
SWA_NAME: ${{ needs.deploy_swa_job.outputs.staticSiteName || 'digital-reflections' }}
SWA_RESOURCE_GROUP: ${{ needs.deploy_swa_job.outputs.staticSiteResourceGroupName || 'digital-reflections' }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
lfs: false
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Azure login
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
enable-AzPSSession: true
auth-type: SERVICE_PRINCIPAL
- name: Get Deployment Token for Static Web App
run: |
api_key=$(az staticwebapp secrets list \
--name "${SWA_NAME}" \
--resource-group "${SWA_RESOURCE_GROUP}" \
--query properties.apiKey \
--output tsv)
echo "AZ_SWA_DEPLOYMENT_TOKEN=${api_key}" >> "${GITHUB_ENV}"
- name: Build And Deploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ env.AZ_SWA_DEPLOYMENT_TOKEN }}
repo_token: ${{ secrets.GH_SWA_TOKEN }}
action: "upload"
app_location: "/"
api_location: ""
output_location: "/build"
app_build_command: "npm install && npm run build"
Why this is idempotent​
Idempotency is mostly about avoiding stale secrets and external dependencies:
- Infra first: Bicep provisions the SWA and outputs the default hostname.
- DNS before custom domain: Cloudflare CNAME is created/updated before Azure attempts validation.
- Runtime SWA token: The deployment token is fetched at runtime instead of stored as a secret.
This means you can run the pipeline in a fresh subscription and still end up with a working site.
Secrets you need​
Store these in GitHub secrets:
AZURE_CREDENTIALS: service principal JSON forazure/login.GH_SWA_TOKEN: GitHub PAT with admin access to the repo (used during SWA provisioning).CLOUDFLARE_API_TOKEN: Zone read + DNS edit.CLOUDFLARE_ZONE_ID: Cloudflare zone id.
Cloudflare DNS upsert​
To keep the workflow readable, the DNS upsert logic is in:
#!/usr/bin/env bash
set -euo pipefail
if [[ -z "${CF_API_TOKEN:-}" || -z "${CF_ZONE_ID:-}" ]]; then
echo "Cloudflare secrets are not set."
exit 1
fi
if [[ -z "${RECORD_NAME:-}" || -z "${RECORD_CONTENT:-}" ]]; then
echo "Cloudflare record name or content is missing."
exit 1
fi
api="https://api.cloudflare.com/client/v4"
headers=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json")
existing=$(curl -sS "${headers[@]}" \
"${api}/zones/${CF_ZONE_ID}/dns_records?type=CNAME&name=${RECORD_NAME}")
record_id=$(echo "${existing}" | jq -r '.result[0].id // empty')
payload=$(jq -n \
--arg type "CNAME" \
--arg name "${RECORD_NAME}" \
--arg content "${RECORD_CONTENT}" \
'{type:$type,name:$name,content:$content,ttl:1,proxied:false}')
if [[ -n "${record_id}" ]]; then
response=$(curl -sS -X PUT "${headers[@]}" \
--data "${payload}" \
"${api}/zones/${CF_ZONE_ID}/dns_records/${record_id}")
else
response=$(curl -sS -X POST "${headers[@]}" \
--data "${payload}" \
"${api}/zones/${CF_ZONE_ID}/dns_records")
fi
success=$(echo "${response}" | jq -r '.success')
if [[ "${success}" != "true" ]]; then
echo "Cloudflare DNS update failed:"
echo "${response}"
exit 1
fi
The script:
- Looks up an existing CNAME for
www.digital-reflections.com. - Updates it if found, or creates it if missing.
- Forces DNS-only mode (no proxy) for SWA validation.
Build and deploy​
The build job:
- Uses
actions/setup-node@v4with npm cache. - Fetches the SWA deployment token via
az staticwebapp secrets list. - Uploads the build output using
Azure/static-web-apps-deploy@v1.
Pipeline optimizations​
To keep things fast and predictable:
- paths-filter: Infra only runs on
infra/**. App deploy runs on everything else. - concurrency: Cancels in-progress runs on the same branch.
- npm cache: Speeds up builds by caching dependencies.
Troubleshooting notes​
Common problems and fixes:
- Cloudflare auth error (code 10000): Token is invalid or missing DNS edit permission.
- CNAME invalid during Azure validation: DNS not created yet, or proxy is enabled.
- Empty SWA hostname: Re-run
az staticwebapp showto confirm the resource exists.
Wrap-up​
This setup is intentionally boring: predictable, repeatable, and easy to re-run. If you want the full details, the deployment notes live in:
