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 "zavaexchangegift-github-cicd" \
--role contributor \
--scopes /subscriptions/{subscription-id} --json-authOutput looks like:
{
"appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"displayName": "zavaexchangegift-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
- Click New repository secret
- Name:
AZURE_CREDENTIALS - Value: Paste the entire JSON output from the service principal creation above
- Click Add secret
That's it! One secret, no variables needed.
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 (zavaexchangegift-qa)
├─→ [Deploy QA Infrastructure] ←─ SWA (Free), Cosmos DB (Free Tier), Email enabled
├─→ [Deploy to QA] ←─ QA Static Web App URL
│
└─ If deployment PASSED:
├─→ [Create/Verify Prod RG] ←─ auto-created (zavaexchangegift)
├─→ [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 "zavaexchangegift-github-cicd" \
--role contributor \
--scopes /subscriptions/$(az account show --query id -o tsv)Output:
{
"appId": "12345678-1234-1234-1234-123456789012",
"displayName": "zavaexchangegift-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:
zavaexchangegift-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": "zavaexchangegift-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)
# zavaexchangegift-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: zavaexchangegift-pr-42
Cosmos DB: ssa1b2c3d4e5f6g7h
Static Web App: zavaexchangegift-pr-42-a1b2c3d4e5f
Scenario 2: Bob forks the repo and deploys prod to his subscription
Resource Group: zavaexchangegift
Cosmos DB: ssx9y8z7w6v5u4t
Static Web App: zavaexchangegift-prod-x9y8z7w6v5
(Different uniqueSuffix = no conflicts!)
Scenario 3: Carol deploys QA from her fork
Resource Group: zavaexchangegift
Cosmos DB: ssk1l2m3n4o5p6q
Static Web App: zavaexchangegift-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 zavaexchangegift-qa RG, deploy free tier resources
4. [2 min] Deploy to QA
└─ Static Web App upload
5. [3 min] Deploy Production Infrastructure
└─ Deploy to zavaexchangegift 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:
zavaexchangegift-pr-{PR_NUMBER}- Static Web App (Free SKU) with preview deployment
- Cosmos DB (Serverless, free tier)
- 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:
- Free tier resources mostly
- Minimal storage charges if any
- ~$0 for most PRs
Purpose: Test against production-like infrastructure before release, completely isolated from production
Resources:
- Isolated Resource Group:
zavaexchangegift-qa(separate from production) - Static Web App (Free SKU) - no staging slots
- Cosmos DB (Free Tier - 1000 RU/s, 25GB storage, sufficient for testing)
- Application Insights (30-day retention)
- Azure Communication Services (email enabled for full testing)
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:
- Free Static Web App: $0/month
- Cosmos DB (Free Tier): $0/month (one free tier per subscription)
- Application Insights: ~$0-5/month (low volume)
- Azure Communication Services: ~$1/month (test emails only)
Total QA Cost: ~$1-6/month
Purpose: Live application for end users
Resources:
- Resource Group:
zavaexchangegift - 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 Free tier Static Web App for development
- 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 "zavaexchangegift-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 zavaexchangegift \
--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
zavaexchangegift-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 zavaexchangegift-qa \
--resource-group zavaexchangegift-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 "zavaexchangegift-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
Google Analytics Integration:
This application includes optional Google Analytics for anonymous usage tracking:
- Only loads in production: Non-production environments (PR previews, QA) do not load Google Analytics
- Requires user consent: A cookie consent banner appears on first visit, requiring explicit user approval
- GDPR compliant: Users can decline analytics via the cookie consent banner on first visit, or change their preference at any time via the Privacy Policy page
- Anonymous data only: Collects page views and interaction patterns without identifying users
- Tracking ID: Configured via
VITE_GA_TRACKING_IDenvironment variable (disabled by default)
⚠️ Security Note:
By default, Google Analytics will send the full page URL (including query parameters) to Google. If any access tokens or sensitive credentials (such as?code=...&participant=...or?organizer=...) are present in the URL, they will be leaked to Google Analytics and could be reused to join protected games or access the organizer panel.
To mitigate this risk:
Avoid including sensitive tokens in URLs wherever possible.
If tokens must be present in the URL, configure your GA integration to strip sensitive query parameters before sending data.
For example, when callinggtag('config', ...), override thepage_locationorpage_pathto exclude sensitive parameters:// Example: Remove sensitive query params before sending to GA const url = new URL(window.location.href); url.searchParams.delete('code'); url.searchParams.delete('participant'); url.searchParams.delete('organizer'); gtag('config', 'G-XXXXXXX', { page_location: url.origin + url.pathname, // or url.toString() if safe });Update this documentation if you change how tokens are handled or how analytics is configured.
Configuring the tracking ID:
You can customize the Google Analytics tracking ID using environment variables:
# During build (production)
VITE_GA_TRACKING_ID=G-YOUR-TRACKING-ID npm run build
# In Azure Static Web Apps, set as an application setting:
# Settings → Configuration → Application settings
# Name: VITE_GA_TRACKING_ID
# Value: G-YOUR-TRACKING-IDIf VITE_GA_TRACKING_ID is not set or is empty, analytics are completely disabled (no tracking data is sent).
Privacy implications for self-hosted deployments:
If you deploy this application for your organization:
- Review your local privacy regulations (GDPR, CCPA, etc.)
- Consider whether analytics are needed for your use case
- Set
VITE_GA_TRACKING_IDto your own Google Analytics property ID (analytics are disabled if not set) - Ensure your privacy policy reflects the use of Google Analytics
- The application stores user consent preferences in browser localStorage
Disabling analytics:
Analytics are automatically disabled in:
- Development environment (
npm run dev) - PR preview environments
- QA environment
- When users decline consent
- When
VITE_GA_TRACKING_IDis not configured (defaults to empty/disabled)
To completely remove analytics functionality, perform all of the following steps:
- Delete
src/lib/analytics.ts. - Remove the import of
analyticsfromsrc/App.tsx. - Delete the
src/components/CookieConsentBanner.tsxcomponent. - Remove all usages of
<CookieConsentBanner />insrc/App.tsx(e.g., line 541). - Remove analytics management UI from
src/components/PrivacyView.tsx. - Remove related translation keys from
src/lib/translations.ts(such as those for analytics/cookie consent).
This ensures that all analytics-related code, UI, and translations are fully removed from the application.
- 📖 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.