Automate your entire deployment pipeline with GitHub Actions. This guide covers setting up service principals, configuring secrets, and understanding the complete CI/CD workflow.
- Quick Setup (10 minutes)
- Architecture
- Prerequisites
- Service Principal Setup
- GitHub Configuration
- Workflow Overview
- Environments Explained
- Best Practices
- Troubleshooting
- Security
# Login to Azure
az login
# Create service principal for CI/CD
az ad sp create-for-rbac \
--name "ZavaGiftExchange-github-cicd" \
--role contributor \
--scopes /subscriptions/{subscription-id}Output looks like:
{
"appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"displayName": "ZavaGiftExchange-github-cicd",
"password": "xxxxxxx~xxxxx_xxx",
"tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}In your GitHub repo, go to Settings → Secrets and variables → Actions:
Secret 1: AZURE_CREDENTIALS (Already created above)
- Value: Paste the entire JSON output from the service principal creation
Secret 2: CLEANUP_SECRET (New)
- This authenticates the scheduled cleanup cron job to call the cleanup HTTP endpoint
- Generate a strong random secret:
# macOS/Linux openssl rand -base64 32 # Windows PowerShell [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }) -as [byte[]])
- Save the same value for both GitHub secret and Azure app settings (see step 3 below)
That's it! Two secrets, no variables needed.
Once both AZURE_CREDENTIALS and CLEANUP_SECRET are in GitHub:
- Merge your changes to
main→ the CI/CD workflow triggers QA and Production deployments - The deployment workflow passes
CLEANUP_SECRETto Bicep only for QA and Production (push tomain) — PR infrastructure deployments do not receive this setting - Bicep configures it as an app setting in the Static Web App for QA and Production environments
- The cleanup endpoint uses this setting to validate the
x-cleanup-secretheader from the scheduled workflow
Note: PR preview environments will not have
CLEANUP_SECRETconfigured and cannot run the cleanup endpoint. This is intentional — the scheduled cleanup workflow only targets QA and Production.
Go to Settings → Environments and create:
Environment: qa
- Set Deployment branches to
mainonly
Environment: production
- Set Deployment branches to
mainonly - Required reviewers: Add team members for approval gate
In Settings → Branches:
- Click Add rule for
mainbranch - Enable:
- ✅ Require a pull request before merging
- ✅ Require status checks to pass before merging
- ✅ Require branches to be up to date before merging
- ✅ Require code review from dismissal of stale pull request reviews
Recommended status checks:
build(Build & Test)e2e(E2E Tests)
Done! Your CI/CD pipeline is ready. 🎉
Pull Request
↓
[Build & Test] ←─ triggers on: PR opened/updated/reopened
↓
[E2E Tests (Local)]
↓
If PASSED:
├─→ [Create PR Resource Group] ←─ ephemeral (auto-deleted when PR closes)
├─→ [Deploy PR Infrastructure] ←─ Dedicated SWA (Free), Cosmos DB (Serverless), App Insights
├─→ [Deploy to Preview] ←─ Static Web App (main slot, no staging)
└─→ [PR Comment] ← shows SWA URL from Bicep output
If FAILED:
└─→ Notify PR author
---
Push to main (after PR merged)
↓
[Build & Test] ←─ triggers on: push to main
↓
[E2E Tests (Local)]
↓
If PASSED:
├─→ [Create/Verify QA RG] ←─ auto-created (ZavaGiftExchange-qa)
├─→ [Deploy QA Infrastructure] ←─ SWA (Standard), Cosmos DB (Serverless), Email enabled
├─→ [Deploy to QA] ←─ QA Static Web App URL
│
└─ If deployment PASSED:
├─→ [Create/Verify Prod RG] ←─ auto-created (ZavaGiftExchange)
├─→ [Deploy Production Infrastructure] ←─ SWA (Standard), Cosmos DB (Serverless)
├─→ [Deploy to Production] ← REQUIRES APPROVAL
└─→ [Deployment Summary] ← documents the release
If any step FAILED:
└─→ Deployment stops, notify authors
- ✅ Azure subscription with billing enabled
- ✅ Minimum roles: Contributor on subscription or resource group
- ✅ Azure CLI installed (
az --version)
- ✅ GitHub repository (you're reading this, so you have it!)
- ✅ Admin access to repository settings (to add secrets)
- ✅ Collaborators with access for review (optional)
A service principal is an identity for your CI/CD pipeline to authenticate with Azure without passwords. GitHub Actions uses:
- OIDC (OpenID Connect): Secure, no secrets needed
- Alternative: Personal access token (less secure, avoid if possible)
# 1. Login to Azure
az login
# 2. List subscriptions
az account list --output table
# 3. Set subscription (if multiple)
az account set --subscription "subscription-id"
# 4. Create service principal
az ad sp create-for-rbac \
--name "ZavaGiftExchange-github-cicd" \
--role contributor \
--scopes /subscriptions/$(az account show --query id -o tsv)Output:
{
"appId": "12345678-1234-1234-1234-123456789012",
"displayName": "ZavaGiftExchange-github-cicd",
"password": "xxxxx~xxxxx_xxx",
"tenant": "87654321-4321-4321-4321-210987654321",
"subscriptionId": "11111111-2222-3333-4444-555555555555"
}Save this entire JSON! You'll paste it as the AZURE_CREDENTIALS secret in GitHub.
- Go to Azure Active Directory → App registrations
- Click New registration
- Name:
ZavaGiftExchange-github-cicd - Click Register
- Copy Application (client) ID
- Go to Certificates & secrets
- Click New client secret
- Copy the value (not the ID)
- Go to Subscription → IAM → Add role assignment
- Assign Contributor role to the app
Current setup uses Contributor role which allows:
- ✅ Create/update/delete resource groups
- ✅ Deploy Bicep templates
- ✅ Create all Azure resources
⚠️ Very permissive (suitable for trusted CI/CD)
For tighter security (advanced):
Create custom role with specific permissions:
az role assignment create \
--assignee {appId} \
--role "Custom CI/CD Role" \
--scope /subscriptions/{subscription-id}Location: Settings → Secrets and variables → Actions
Add this single secret:
| Name | Value | Source |
|---|---|---|
AZURE_CREDENTIALS |
Complete JSON output from service principal | From az ad sp create-for-rbac command (all 5 fields) |
Example value:
{
"appId": "12345678-1234-1234-1234-123456789012",
"displayName": "ZavaGiftExchange-github-cicd",
"password": "xxxxx~xxxxx_xxx",
"tenant": "87654321-4321-4321-4321-210987654321",
"subscriptionId": "11111111-2222-3333-4444-555555555555"
}That's the only secret needed! Much simpler than before.
Location: Settings → Environments
- Click New environment
- Name:
qa - Deployment branches → Select main
- Optional: Add reviewers
- Click Configure environment
- Click New environment
- Name:
production - Deployment branches → Select main
- Required reviewers → Add team members
- Custom deployment branch policies → Optional
- Click Configure environment
Result: Production deployments require manual approval before proceeding.
Location: Settings → Branches
-
Click Add branch protection rule
-
Pattern:
main -
Enable:
- ✅ Require a pull request before merging
- ✅ Dismiss stale pull request approvals when new commits are pushed
- ✅ Require status checks to pass before merging
- ✅ Require branches to be up to date before merging
- ✅ Include administrators (optional but recommended)
-
Select required status checks:
build(Build & Test job)e2e(E2E Tests job)
Result: Code can't be merged to main without passing tests.
Since this is an open-source project, many developers will deploy their own instances to their Azure subscriptions. All Azure resource names must be globally unique, especially:
- Cosmos DB accounts (globally unique across all Azure regions)
- Static Web Apps (unique within Azure)
- Azure Communication Services (unique within Azure)
The Bicep template uses a sophisticated naming strategy that automatically generates unique names:
# Each resource name combines:
var uniqueSuffix = uniqueString(
resourceGroup().id, # Your resource group
deploymentId, # PR number, environment, or run ID
subscription().subscriptionId # Your subscription
)
# Results in names like:
# ss7hx5k9qm2p (Cosmos DB - 24 char limit)
# ZavaGiftExchange-qa-7hx5k9qm2p (Static Web App)
# ss-acs-7hx5k9qm2p (Communication Services)PR Environments:
deploymentId = "pr-{PR_NUMBER}"
# Example: pr-42
# Each PR gets its own dedicated Static Web App
QA & Production:
deploymentId = "qa-stable" or "prod-stable"
# Stable ID ensures consistent resource names across deployments
# Each environment has its own dedicated Static Web App
As a developer, you don't need to do anything special:
- Fork the repo to your account
- Run the workflow
- ✅ Your resources automatically get unique names
- No naming conflicts even if 100 developers deploy simultaneously
Scenario 1: Alice deploys PR #42
Resource Group: ZavaGiftExchange-pr-42
Cosmos DB: ssa1b2c3d4e5f6g7h
Static Web App: ZavaGiftExchange-pr-42-a1b2c3d4e5f
Scenario 2: Bob forks the repo and deploys prod to his subscription
Resource Group: ZavaGiftExchange
Cosmos DB: ssx9y8z7w6v5u4t
Static Web App: ZavaGiftExchange-prod-x9y8z7w6v5
(Different uniqueSuffix = no conflicts!)
Scenario 3: Carol deploys QA from her fork
Resource Group: ZavaGiftExchange-qa
Cosmos DB: ssk1l2m3n4o5p6q
Static Web App: ZavaGiftExchange-qa-k1l2m3n4o5p
(Different uniqueSuffix = no conflicts!)
✅ No more naming conflicts - Cosmos DB, Static Web Apps, etc.
✅ Multiple developers can deploy simultaneously - Each gets unique resources
✅ Safe for public open-source - 100+ forks won't interfere with each other
✅ Automatic naming - No manual configuration needed
The CI/CD workflow runs automatically in these cases:
1. Pull Request (Any activity)
on:
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]Runs: Build → Test → Deploy PR infrastructure → Preview → Comment
2. Push to Main
on:
push:
branches: [main]Runs: Build → Test → Deploy QA → QA Tests → Deploy Production
3. Manual Trigger (Future) You can add workflow_dispatch to allow manual triggers:
on:
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'staging'1. [1 min] Build & Test
└─ npm build, lint, tests
2. [3 min] E2E Tests (Local)
└─ Playwright against mock environment
3. [3 min] Deploy PR Infrastructure
└─ az group create, bicep deploy (dedicated SWA)
4. [2 min] Deploy Preview
└─ Static Web App (main slot)
Total: ~10 minutes
Result: SWA URL posted to PR comment ✅
1. [1 min] Build & Test
└─ npm build, lint, tests
2. [3 min] E2E Tests (Local)
└─ Playwright against mock environment
3. [3 min] Deploy QA Infrastructure
└─ Create/verify ZavaGiftExchange-qa RG, deploy Standard tier resources
4. [2 min] Deploy to QA
└─ Static Web App upload
5. [3 min] Deploy Production Infrastructure
└─ Deploy to ZavaGiftExchange RG (Standard SWA, Serverless Cosmos DB)
6. [WAIT] 🔔 AWAITING APPROVAL 🔔
└─ Required reviewer must approve
└─ Review → Approve → Continue
7. [2 min] Deploy to Production
└─ Static Web App upload to production
Total: ~15 minutes (excluding approval wait time)
Result: App live in production ✅
Purpose: Preview changes before merge
Resources created per PR:
- Resource Group:
ZavaGiftExchange-pr-{PR_NUMBER}- Static Web App (Standard SKU) with preview deployment
- Cosmos DB (Serverless)
- Application Insights
- Log Analytics Workspace
Lifecycle:
- Created when PR opens
- Updated when new commits pushed
- Automatically deleted when PR closes/merges ♻️
Access:
- URL posted in PR comment
- Valid for lifetime of PR
- Anyone with GitHub access can view
Costs:
- Standard tier SWA: ~$9/month (prorated for PR lifetime)
- Serverless Cosmos DB: ~$0 (pay per request)
- ~$1-10 for most PRs
Purpose: Test against production-like infrastructure before release, completely isolated from production
Resources:
- Isolated Resource Group:
ZavaGiftExchange-qa(separate from production) - Static Web App (Standard SKU)
- Cosmos DB (Serverless - pay per request)
- Application Insights (30-day retention)
- Azure Communication Services (email enabled for full testing)
- Azure Load Testing resource
Lifecycle:
- Created on first deployment
- Updated on every push to
main - Persists indefinitely
- Completely isolated from production data
Access:
- URL: Unique Azure-assigned URL (from deployment output)
- Accessible to team members only (if configured)
Costs:
- Standard Static Web App: ~$9/month
- Cosmos DB (Serverless): ~$0-5/month (pay per request)
- Application Insights: ~$0-5/month (low volume)
- Azure Communication Services: ~$1/month (test emails only)
Total QA Cost: ~$10-20/month
Purpose: Live application for end users
Resources:
- Resource Group:
ZavaGiftExchange - Static Web App (Standard SKU) - SLA, custom domains support
- Cosmos DB (Serverless - unlimited scaling, pay per request)
- Application Insights (90-day retention)
- Azure Communication Services (email enabled)
Lifecycle:
- Deployed only after approval
- Persists until manually deleted
- Auto-scales based on usage
Access:
- Public URL: Custom domain or Azure-assigned
- Monitored continuously
Costs:
- Standard Static Web App: ~$9/month
- Cosmos DB (Serverless): ~$5-50/month (scales with traffic, no limits)
- Application Insights: ~$5-50/month (depends on telemetry volume)
- Azure Communication Services: $0.07 per email
Total Production Cost: ~$20-110/month depending on usage
Recommended: GitHub Flow
main (production)
↑
└─← pull requests (feature branches)
└─ pr/add-translations
└─ pr/fix-cosmos-bug
└─ pr/update-ui
Process:
- Create feature branch from
main - Make changes, push commits
- CI/CD runs build → tests → preview
- Review PR comments and preview
- Get code review (2+ reviewers recommended)
- Merge when ready
- CI/CD auto-deploys to QA
- Test in QA environment
- Manual approval triggers production deploy
Use clear, semantic commit messages:
✅ Good:
- "feat: add French translations"
- "fix: cosmos db connection timeout"
- "docs: update local development guide"
- "test: add E2E test for language toggle"
❌ Bad:
- "Update stuff"
- "Fix bugs"
- "asdasd"
- "temp"
Clear titles help with understanding changes:
✅ Good:
- "Add support for 9 languages including German and Dutch"
- "Fix email notification service initialization"
- "Refactor authentication logic for better security"
❌ Bad:
- "WIP"
- "Changes"
- "Stuff"
Keep environments similar:
- Dev (local): Most permissive, email disabled
- QA: Production-like, all features enabled
- Production: Locked down, monitoring enabled
In Azure Portal:
- Go to Application Insights
- Set up alerts for:
- Failed requests > 5% of total
- Server response time > 5s
- Availability tests failing
- Configure email/SMS notifications
- ✅ Use service principals (not personal access tokens)
- ✅ Use OIDC (not passwords)
- ✅ Rotate credentials every 90 days
- ✅ Use branch protection rules
- ✅ Require code reviews
- ✅ Keep dependencies updated
- ✅ Enable Advanced Security if available
Monitor spending:
- Go to Cost Management + Billing in Azure Portal
- Set budgets and alerts
- Review monthly costs
- Clean up unused PR environments
Cost saving tips:
- Delete old PR environments manually if needed
- Use Serverless Cosmos DB (pay per request)
- Monitor Application Insights costs
Problem: npm: command not found
Solution:
# Verify Node version in workflow:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20' # Must match package.json "engines"Problem: tsc: command not found
Solution:
# Make sure TypeScript is in devDependencies
npm list typescript
# If missing:
npm install -D typescriptProblem: invalid json provided to creds parameter
Solution:
- Verify
AZURE_CREDENTIALSsecret contains valid JSON - Check the secret includes all 5 fields:
appIddisplayNamepasswordtenantsubscriptionId
- Copy-paste the entire JSON output from
az ad sp create-for-rbacwithout modification
Problem: The operation was not successful. Status code: 401. Error message: Authorization failed
Solution:
- Service principal may have expired (90+ days)
- Create new service principal:
az ad sp create-for-rbac \ --name "ZavaGiftExchange-github-cicd-new" \ --role contributor \ --scopes /subscriptions/{subscription-id} - Update
AZURE_CREDENTIALSsecret with new JSON - Delete old service principal when tested
Problem: ERROR: The Bicep file could not be parsed
Solution:
# Validate Bicep locally
az bicep build --file ./infra/main.bicep
# Check syntax
az deployment group validate \
--resource-group ZavaGiftExchange \
--template-file ./infra/main.bicep \
--parameters ./infra/parameters.prod.jsonProblem: Insufficient permissions for operation
Solution:
# Verify service principal has Contributor role
az role assignment list \
--assignee {appId} \
--output table
# Grant role if missing
az role assignment create \
--assignee {appId} \
--role "Contributor" \
--scope /subscriptions/{subscription-id}Problem: Cannot read deployment token
Solution:
- Verify Static Web App exists in
ZavaGiftExchange-qaresource group - Check Azure CLI is authenticated with correct subscription
- Verify step "Get Static Web App Token" succeeded
Problem: 401 Unauthorized
Solution:
# Verify Static Web App token
az staticwebapp secrets list \
--name ZavaGiftExchange-qa \
--resource-group ZavaGiftExchange-qa
# Token should be returned successfullyProblem: Timeout waiting for element
Solution:
- Check QA app is fully deployed and responsive
- Increase Playwright timeout in
playwright.config.ts - Verify workflow step "Get Static Web App Token" succeeded (base URL derives from infrastructure output)
Problem: Deployment stuck in "Awaiting Approval"
Solution:
- Go to Actions → specific workflow run
- See "Waiting" section with "Review deployments"
- Click "Review deployments"
- Approve for "production"
- Workflow continues
What's stored as secrets:
AZURE_CREDENTIALS✅ JSON containing all needed authentication infoappId(public ID)tenant(public ID)subscriptionId(public ID)password(secret, only stored once, uses OIDC for authentication)
What's NOT stored:
- Individual client ID / tenant ID / subscription ID variables
- Connection strings ❌ Auto-generated in Azure
- API keys ❌ Auto-generated in Static Web App
- Deployment tokens ❌ Retrieved dynamically from Azure CLI during workflow
Simpler approach:
- Only 1 secret to manage (vs 3 before)
- All auth info in one place
- Easier to rotate (regenerate 1 secret)
Every 90 days:
# Create new service principal
az ad sp create-for-rbac \
--name "ZavaGiftExchange-github-cicd-new" \
--role contributor \
--scopes /subscriptions/{subscription-id}
# Copy entire JSON output
# Update AZURE_CREDENTIALS secret in GitHub
# Delete old service principal after testing
az ad sp delete --id {old-appId}GitHub tracks all deployments:
- Go to repo → Deployments
- View who deployed what, when
- See all environment activity
Azure tracks deployments in Activity Log:
- Go to Resource Group → Activity log
- Filter by Deployments
- See all infrastructure changes
- 📖 Read getting-started.md for local development
- 🔗 Set up custom domain
- 📊 Configure monitoring & alerts
- 💰 Review cost optimization
- 🔐 Enable Microsoft Entra ID authentication
Q: Do I need to manually create resource groups?
A: No, the workflow creates all resource groups automatically (PR, QA, and Production). Just add the AZURE_CREDENTIALS secret and push to trigger deployments.
Q: Can I deploy to production without approval?
A: Remove the production environment from settings, but NOT recommended for production code.
Q: How do I rollback a deployment? A: Redeploy previous commit. GitHub Actions maintains full deployment history.
Q: What if Azure resources are deleted accidentally? A: Re-deploy using the workflow. Bicep templates are idempotent (safe to run multiple times).
Q: How do I add more team members? A: Go to repo → Settings → Collaborators → Add person. They'll need approval if branch protection enabled.
Q: Can I use a different Azure subscription?
A: Yes, recreate the service principal for the new subscription and update the AZURE_CREDENTIALS secret.
Q: What if deployment tokens expire? A: Azure automatically regenerates them. Workflow handles this.
Questions? Open an issue or start a discussion.