Skip to main content

Deploying an Azure Static Web App Using GitHub Actions

· 6 min read
Craig Dempsey
Cloud Devops Engineer @ Digital Reflections

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:

  1. Detect changes to decide whether infra or app deploy should run.
  2. Provision SWA infra with Bicep.
  3. Upsert Cloudflare DNS for the custom domain.
  4. Validate the custom domain in Azure.
  5. Fetch the SWA deployment token at runtime.
  6. 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.

.github/workflows/deploy.yml (excerpt)
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 for azure/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:

scripts/cloudflare-upsert-cname.sh
#!/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@v4 with 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 show to 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:

docs/docusaurus/deploying-docusaurus.md