
Azure Integrations
- 12 installs
- 7 repo stars
- Updated August 2, 2026
- practicalswan/agent-skills
azure-integrations is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
Key points
- azure-integrations
- AI & Agent Building
- AI-coding skill
Azure Integrations by the numbers
- 12 all-time installs (skills.sh)
- Ranked #11,618 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/practicalswan/agent-skills --skill azure-integrationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 7 |
| Last updated | August 2, 2026 |
| Repository | practicalswan/agent-skills ↗ |
How do I helps with ai & agent building tasks?
Helps with ai & agent building tasks.
Who is it for?
Best when you're working on ai & agent building and need structured help with azure-integrations.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks, or when azure-integrations is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted development.
What you get
Structured output aligned to azure-integrations: azure-integrations; AI & Agent Building; AI-coding skill.
Files
Azure Integrations
Tech Stack Target / Version: Azure Static Web Apps, App Service, Bicep or ARM templates, GitHub Actions, Node.js 20+, and Azure CLI 2.60+.
Deployment and integration patterns for Azure cloud services, focusing on web application hosting, CI/CD automation, and infrastructure as code.
- Leverage native parallel subagent dispatch and 200k+ context windows where available.
When to Use This Skill
Use symptom -> action triggers: when one matches, apply this skill and verify with the protocol below.
- Deploying Next.js or Vite/React apps to Azure
- Setting up CI/CD workflows with GitHub Actions
- Configuring Azure Static Web Apps or App Service
- Managing Azure Blob Storage for file uploads
- Using Azure Key Vault for secure secrets management
- Setting up Application Insights for monitoring
- Writing Bicep or ARM templates for infrastructure as code
- Integrating Azure Cosmos DB (MongoDB API) with applications
Anti-Patterns
- Changing infrastructure before inspecting the current state: Cloud drift and hidden dependencies make blind edits risky.
- Hardcoding credentials or environment assumptions: Rollouts stop being reproducible and secrets become harder to rotate.
- Skipping rollback, observability, or validation planning: You only notice the missing safeguards after the deployment is already live.
Verification Protocol
Before claiming "skill applied successfully":
1. Pass/fail: The Azure Integrations implementation names the target runtime, framework version, and affected files. 2. Pass/fail: Build, lint, test, or equivalent local validation is run for the changed surface. 3. Pass/fail: Edge cases for errors, dependency drift, and environment differences are addressed or explicitly out of scope. 4. Pressure-test scenario: Apply the workflow to a change that passes happy-path tests but fails one boundary condition. 5. Success metric: Zero untested success claims; every implementation claim maps to a command or artifact.
Available Deployment Scripts
- [examples/vite-swa-deployment.md](./examples/vite-swa-deployment.md) - Vite/React to Static Web Apps
- [examples/nextjs-app-service-deployment.md](./examples/nextjs-app-service-deployment.md) - Next.js to App Service with Key Vault, App Insights, Storage
- [scripts/deploy-swa.ps1](./scripts/deploy-swa.ps1) - PowerShell deployment script for SWA
- [scripts/deploy-appservice.ps1](./scripts/deploy-appservice.ps1) - PowerShell deployment script for App Service
Reference Documentation
- [references/bicep-quickref.md](./references/bicep-quickref.md) - Bicep template patterns and syntax
- [references/github-actions-azure.md](./references/github-actions-azure.md) - GitHub Actions patterns for Azure deployments
---
Azure Static Web Apps
Deployment Setup
name: Deploy to Azure Static Web Apps
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and Deploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "/"
api_location: "api"
output_location: "dist"Configuration (staticwebapp.config.json)
{
"routes": [
{ "route": "/api/*", "allowedRoles": ["authenticated"] },
{ "route": "/*", "serve": "/index.html", "statusCode": 200 }
],
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/api/*", "*.{css,js,png,jpg,svg,ico}"]
},
"globalHeaders": {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "default-src 'self'"
},
"responseOverrides": {
"401": { "redirect": "/login", "statusCode": 302 }
}
}Best Practices
- Use staging environments for PR previews
- Configure custom domains with SSL
- Set up authentication providers (Azure AD, GitHub, etc.)
- Use API routes for serverless backend functions
---
Azure App Service
Full end-to-end deployment guide with Key Vault integration, Application Insights, and Azure Storage available in examples/nextjs-app-service-deployment.md.
Quick Reference
| Resource | Purpose |
|---|---|
| App Service Plan | Linux hosting for Node.js apps |
| Web App | Next.js application instance |
| Key Vault | Secure secrets management (MongoDB URI, auth secrets) |
| Storage Account | File uploads (recipe images) |
| Application Insights | Monitoring and telemetry |
| Managed Identity | Service-to-service authentication (no passwords) |
PowerShell Deployment Script
Automated deployment script: [scripts/deploy-appservice.ps1](./scripts/deploy-appservice.ps1)
.\deploy-appservice.ps1 -AppName "my-app" -ResourceGroup "rg-my-app" -Sku S1 -EnableAppInsights -EnableStorageDeployment Workflow
name: Deploy Next.js to App Service
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC authentication
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install and Build
run: |
npm ci
npm run build
- name: Azure Login (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 App Service
uses: azure/webapps-deploy@v3
with:
app-name: kitchen-odyssey
package: .Key Features Covered in Full Guide
- ✅ Zero-downtime deployments with deployment slots
- ✅ Key Vault integration for secure secrets
- ✅ Managed Identity authentication (no secrets in app settings)
- ✅ Application Insights for monitoring and telemetry
- ✅ Azure Storage for file uploads with CORS
- ✅ Bicep infrastructure as code templates
- ✅ Production-ready security headers
- ✅ Automated CI/CD with GitHub Actions
App Settings (Key Vault Pattern)
az webapp config appsettings set --name <app-name> --resource-group <rg> --settings \
AZURE_KEYVAULT_RESOURCEENDPOINT="https://<vault-name>.vault.azure.net" \
AZURE_CLIENTID="<Managed-Identity-Client-ID>" \
NEXTAUTH_URL="https://<app-name>.azurewebsites.net" \
NODE_ENV="production"Legacy Pattern (not recommended - secrets exposed in app settings):
az webapp config appsettings set --name <app-name> --resource-group <rg> --settings \
MONGODB_URI="mongodb+srv://..." \
NEXTAUTH_SECRET="..."---
Azure Blob Storage
Upload Integration
import { BlobServiceClient } from '@azure/storage-blob';
const blobServiceClient = BlobServiceClient.fromConnectionString(
process.env.AZURE_STORAGE_CONNECTION_STRING
);
export async function uploadFile(containerName, fileName, buffer, contentType) {
const containerClient = blobServiceClient.getContainerClient(containerName);
await containerClient.createIfNotExists({ access: 'blob' });
const blockBlobClient = containerClient.getBlockBlobClient(fileName);
await blockBlobClient.uploadData(buffer, {
blobHTTPHeaders: { blobContentType: contentType },
});
return blockBlobClient.url;
}
export async function deleteFile(containerName, fileName) {
const containerClient = blobServiceClient.getContainerClient(containerName);
const blockBlobClient = containerClient.getBlockBlobClient(fileName);
await blockBlobClient.deleteIfExists();
}
export async function generateSasUrl(containerName, fileName, expiresInMinutes = 60) {
const containerClient = blobServiceClient.getContainerClient(containerName);
const blobClient = containerClient.getBlobClient(fileName);
const sasUrl = await blobClient.generateSasUrl({
permissions: BlobSASPermissions.parse('r'),
expiresOn: new Date(Date.now() + expiresInMinutes * 60 * 1000),
});
return sasUrl;
}---
Bicep Templates
Web App + Cosmos DB
@description('The name of the web app')
param appName string
@description('The location for resources')
param location string = resourceGroup().location
@description('The SKU of the App Service Plan')
param sku string = 'B1'
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: '${appName}-plan'
location: location
sku: {
name: sku
}
properties: {
reserved: true
}
kind: 'linux'
}
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: appName
location: location
properties: {
serverFarmId: appServicePlan.id
siteConfig: {
linuxFxVersion: 'NODE|20-lts'
appSettings: [
{ name: 'MONGODB_URI', value: cosmosDb.listConnectionStrings().connectionStrings[0].connectionString }
{ name: 'NODE_ENV', value: 'production' }
]
}
}
}
resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = {
name: '${appName}-db'
location: location
kind: 'MongoDB'
properties: {
databaseAccountOfferType: 'Standard'
capabilities: [{ name: 'EnableMongo' }]
locations: [{ locationName: location, failoverPriority: 0 }]
}
}
output webAppUrl string = 'https://${webApp.properties.defaultHostName}'Deployment
az deployment group create \
--resource-group myResourceGroup \
--template-file main.bicep \
--parameters appName=my-recipe-app---
GitHub Actions CI/CD Patterns
Multi-Environment Pipeline
name: CI/CD Pipeline
on:
push:
branches: [main, staging]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run lint
- run: npm run test
deploy-staging:
needs: test
if: github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: azure/webapps-deploy@v3
with:
app-name: ${{ secrets.STAGING_APP_NAME }}
publish-profile: ${{ secrets.STAGING_PUBLISH_PROFILE }}
deploy-production:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: azure/webapps-deploy@v3
with:
app-name: ${{ secrets.PROD_APP_NAME }}
publish-profile: ${{ secrets.PROD_PUBLISH_PROFILE }}---
Troubleshooting
| Issue | Solution |
|---|---|
| SWA deployment fails | Check output_location matches build output directory |
| App Service 500 errors | Check application logs: az webapp log tail |
| Blob upload CORS | Configure CORS rules on storage account |
| Bicep deployment error | Validate: az bicep build --file main.bicep |
| Environment variables missing | Check App Settings in Azure Portal |
| Cold start latency | Use Always On for App Service, or premium SWA tier |
---
References & Resources
Documentation
- Bicep Quick Reference — Bicep template patterns, resource declarations, modules, and common Azure resource examples
- GitHub Actions for Azure — GitHub Actions workflows for Azure deployments, OIDC setup, multi-stage pipelines
Scripts
- Deploy to Azure SWA — PowerShell script to deploy Vite/React or Next.js apps to Azure Static Web Apps
Examples
- Vite SWA Deployment Guide — End-to-end walkthrough deploying a Vite+React app to Azure Static Web Apps
---
<!-- PORTABILITY:START -->
Cross-Client Portability
This skill is written to stay usable across GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- GitHub Copilot: keep the folder in a Copilot-visible skill or plugin path, or wrap the workflow as project instructions if the host does not support portable skill folders directly.
- Claude Code: keep the folder in a local skills directory or a compatible plugin or marketplace source.
- Codex: install or sync the folder into
$CODEX_HOME/skills/<skill-name>and restart Codex after major changes. - Gemini CLI: this repository generates a project command named
/skills:azure-integrationsfrom this skill. Rebuild commands withpython scripts/export-gemini-skill.py azure-integrationsand then run/commands reloadinside Gemini CLI.
<!-- PORTABILITY:END -->
<!-- MCP:START -->
MCP Availability And Fallback
Preferred MCP Server: Azure MCP
- Fallback prompt: "Use the Azure Integrations skill without MCP. Rely on the local
SKILL.md, bundled references or scripts, and manual verification. Show the exact commands, evidence, and final checks you used before concluding." - Use Azure CLI (
az), Bicep or ARM templates, and the bundled PowerShell deployment scripts in this skill when the MCP server is unavailable. - Validate resources with
azqueries, deployment logs, and portal checks before closing the task.
<!-- MCP:END -->
Related Skills
- powerbi-modeling: Use it when the workflow also needs Power BI semantic model design and DAX work.
- microsoft-development: Use it when the workflow also needs microsoft development guidance.
- sql-development: Use it when the workflow also needs SQL query, schema, and performance tuning work.
- documentation-authoring: Use it when the workflow also needs drafting structured technical or product documents.
Changelog
[2026-04-25] - Version 1.2 Verification Protocol Refresh
Added
- Added a
Verification Protocolsection with skill-specific pass/fail checks, one pressure-test scenario, and a measurable success metric. - Added guidance to leverage native parallel subagent dispatch and 200k+ context windows where available.
Changed
- Updated
SKILL.mdfrontmatter toversion: "1.2"andlast_updated: 2026-04-25. - Reframed activation guidance toward symptom -> action triggers and standardized two-stage review wording where applicable.
[2026-04-24] - Version 1.1 Refresh
Changed
- Updated the SKILL frontmatter version to
1.1for the 2026-04-24 catalog refresh.
[2026-04-24] - Skill Refresh
Changed
- Standardized the SKILL frontmatter with version metadata, last-updated date, tags, and a concise catalog description.
- Reformatted the portability and MCP guidance with a preferred server line, a copy-paste fallback prompt, and consistent bullet lists.
- Added a catalog-standard Anti-Patterns section and refreshed the Related Skills links at the end of the skill.
- Added a Tech Stack Target / Version note to anchor the guidance to current Azure and Node.js deployment tooling.
[2026-04-24] - Catalog Audit Cleanup
Fixed
- Removed obsolete standalone Skill Paths guidance that duplicated the generated portability section.
- Removed a stray closing code fence so the Markdown structure is balanced after cleanup.
All notable changes to this skill will be documented in this file.
[2026-04-04] - Cross-Client Portability Refresh
Changed
- Added a standard portability note covering GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- Documented the preferred MCP server surface for this skill and a local no-MCP fallback workflow.
Tested
- Validated
SKILL.mdfrontmatter, portability sections, and Gemini export readiness withpython scripts/validate-skills.py.
[2026-03-09] - Workspace Modernization
Fixed
- Replaced
scripts/deploy-appservice.ps1with a valid App Service zip-deployment workflow that parses and runs cleanly
Changed
- Removed duplicated related-skill content from
SKILL.md
[2026-02-28] — Description Rewrite & Cross-References
Changed
- Rewrote skill description to ~200 characters with clear, specific activation keywords
- Improved keyword specificity to reduce overlap with related skills
Added
## Related Skillscross-reference table with 2-4 related skills and "Use When" guidance
End-to-End: Deploy a Next.js App to Azure App Service
Complete walkthrough from zero to production with CI/CD, Key Vault integration, Application Insights, and Azure Storage for file uploads.
---
Prerequisites
- Node.js 20+ and npm/pnpm
- Azure CLI installed and logged in (
az login) - A GitHub repository with your Next.js project
- An Azure subscription (free tier works)
---
1. Project Structure
my-nextjs-app/
├── public/
│ └── favicon.svg
├── src/
│ ├── app/
│ ├── components/
│ ├── lib/
│ └── middleware.js
├── .github/
│ └── workflows/
│ └── deploy-appservice.yml ← CI/CD workflow
├── infra/
│ └── main.bicep ← Infrastructure as code
├── appsettings.local.json ← Local environment variables
├── package.json
├── next.config.js
└── next-env.d.ts---
2. Azure Resource Setup
Option A: Azure CLI (Step-by-Step)
# Variables
RG_NAME="rg-my-nextjs-app"
LOCATION="eastus"
APP_NAME="my-nextjs-app"
APP_SERVICE_PLAN="${APP_NAME}-plan"
STORAGE_ACCOUNT="${APP_NAME}stg"
KEY_VAULT="${APP_NAME}-kv"
COSMOS_DB="${APP_NAME}-db"
# 1. Create resource group
az group create --name $RG_NAME --location $LOCATION
# 2. Create App Service Plan (Linux, B1 tier)
az appservice plan create \
--name $APP_SERVICE_PLAN \
--resource-group $RG_NAME \
--location $LOCATION \
--is-linux \
--sku B1
# 3. Create Web App (Node.js 20)
az webapp create \
--name $APP_NAME \
--resource-group $RG_NAME \
--plan $APP_SERVICE_PLAN \
--runtime "NODE|20-lts"
# 4. Enable Application Insights
APPINSIGHTS_NAME="${APP_NAME}-ai"
az monitor app-insights component create \
--app $APPINSIGHTS_NAME \
--location $LOCATION \
--resource-group $RG_NAME \
--application-type web
APPINSIGHTS_KEY=$(az monitor app-insights component show \
--app $APPINSIGHTS_NAME \
--resource-group $RG_NAME \
--query instrumentationKey -o tsv)
# 5. Create Storage Account for file uploads
az storage account create \
--name $STORAGE_ACCOUNT \
--resource-group $RG_NAME \
--location $LOCATION \
--sku Standard_LRS \
--kind StorageV2
# 6. Create container for recipe images
az storage container create \
--name recipe-images \
--account-name $STORAGE_ACCOUNT \
--auth-mode login
# 7. Create Key Vault for secrets
az keyvault create \
--name $KEY_VAULT \
--resource-group $RG_NAME \
--location $LOCATION
# 8. Create Cosmos DB (MongoDB API)
az cosmosdb create \
--name $COSMOS_DB \
--resource-group $RG_NAME \
--location $LOCATION \
--kind MongoDB
# 9. Get connection strings
MONGODB_URI=$(az cosmosdb keys list \
--name $COSMOS_DB \
--resource-group $RG_NAME \
--query connectionStrings[0].connectionString -o tsv)
STORAGE_CONNECTION=$(az storage account show-connection-string \
--name $STORAGE_ACCOUNT \
--resource-group $RG_NAME \
--query connectionString -o tsv)
# 10. Store secrets in Key Vault (you'll upload MongoDB password securely)
az keyvault secret set \
--vault-name $KEY_VAULT \
--name "MongoDB-Uri" \
--value "$MONGODB_URI"
az keyvault secret set \
--vault-name $KEY_VAULT \
--name "AppInsights-InstrumentationKey" \
--value "$APPINSIGHTS_KEY"
az keyvault secret set \
--vault-name $KEY_VAULT \
--name "Storage-ConnectionString" \
--value "$STORAGE_CONNECTION"
# 11. Generate NEXTAUTH_SECRET
NEXTAUTH_SECRET=$(openssl rand -base64 32)
az keyvault secret set \
--vault-name $KEY_VAULT \
--name "NextAuth-Secret" \
--value "$NEXTAUTH_SECRET"
# 12. Enable Managed Identity for Web App
az webapp identity assign \
--name $APP_NAME \
--resource-group $RG_NAME
# 13. Get Managed Identity Principal ID
PRINCIPAL_ID=$(az webapp identity show \
--name $APP_NAME \
--resource-group $RG_NAME \
--query principalId -o tsv)
# 14. Grant Managed Identity access to Key Vault
az keyvault set-policy \
--name $KEY_VAULT \
--resource-group $RG_NAME \
--object-id $PRINCIPAL_ID \
--secret-permissions get list
# 15. Grant Managed Identity access to Storage
STORAGE_ID=$(az storage account show \
--name $STORAGE_ACCOUNT \
--resource-group $RG_NAME \
--query id -o tsv)
az role assignment create \
--assignee $PRINCIPAL_ID \
--role "Storage Blob Data Contributor" \
--scope $STORAGE_ID
echo "Deployment complete! Your app URL: https://$APP_NAME.azurewebsites.net"Option B: Bicep Template
Create infra/main.bicep:
@description('Name of the application')
param appName string
@description('Location for all resources')
param location string = resourceGroup().location
@description('App Service Plan SKU')
@allowed(['B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1V3', 'P2V3'])
param sku string = 'B1'
@description('MongoDB Atlas connection string (will be stored in Key Vault)')
@secure()
param mongoDbConnectionString string
var appServicePlanName = '${appName}-plan'
var storageAccountName = toLower('${appName}${uniqueString(resourceGroup().id)}stg')
var keyVaultName = '${appName}${uniqueString(resourceGroup().id)}kv'
var cosmosDbName = '${appName}db'
var appInsightsName = '${appName}ai'
// App Service Plan
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: appServicePlanName
location: location
sku: {
name: sku
size: sku
tier: sku[0] == 'P' ? 'Premium' : (sku[0] == 'S' ? 'Standard' : 'Basic')
}
properties: {
reserved: true
}
kind: 'linux'
}
// Web App
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: appName
location: location
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'NODE|20-lts'
alwaysOn: sku[0] == 'S' || sku[0] == 'P'
appSettings: [
{ name: 'AZURE_KEYVAULT_RESOURCEENDPOINT', value: keyVault.properties.vaultUri }
{ name: 'AZURE_CLIENTID', value: managedIdentity.properties.clientId }
{ name: 'NEXTAUTH_URL', value: 'https://${appName}.azurewebsites.net' }
{ name: 'NODE_ENV', value: 'production' }
{ name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: 'InstrumentationKey=${appInsights.properties.InstrumentationKey}' }
]
}
}
}
// Managed Identity
resource managedIdentity 'Microsoft.Web/sites/config@2023-01-01' = {
name: '${appName}/web'
resourceGroup: resourceGroup()
properties: {
managedServiceIdentityId: webApp.identity.principalId
}
}
// Application Insights
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: appInsightsName
location: location
kind: 'web'
properties: {
Application_Type: 'web'
Flow_Type: 'Bluefield'
IngestionMode: 'ApplicationInsights'
}
}
// Storage Account for file uploads
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageAccountName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
}
}
// Container for recipe images
resource imagesContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = {
parent: storageAccount
name: 'default/recipe-images'
properties: {
publicAccess: 'None'
}
}
// Key Vault
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: keyVaultName
location: location
properties: {
sku: { family: 'A', name: 'standard' }
tenantId: subscription().tenantId
enablePurgeProtection: true
enableSoftDelete: true
enabledForDeployment: true
enabledForTemplateDeployment: true
enabledForDiskEncryption: true
}
}
// Cosmos DB (MongoDB API)
resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = {
name: cosmosDbName
location: location
kind: 'MongoDB'
properties: {
databaseAccountOfferType: 'Standard'
capabilities: [{ name: 'EnableMongo' }]
locations: [{ locationName: location, failoverPriority: 0 }]
}
}
// Store MongoDB URI in Key Vault
resource mongoDbSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
name: '${keyVaultName}/MongoDB-Uri'
properties: {
value: mongoDbConnectionString
}
}
// Store App Insights Key in Key Vault
resource appInsightsSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
name: '${keyVaultName}/AppInsights-InstrumentationKey'
properties: {
value: appInsights.properties.InstrumentationKey
}
}
// Store Storage Connection String in Key Vault
resource storageSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
name: '${keyVaultName}/Storage-ConnectionString'
properties: {
value: listKeys(storageAccount.id, storageAccount.apiVersion).keys[0].value
}
}
// Grant Managed Identity access to Key Vault
resource keyVaultAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(webApp.identity.principalId, keyVault.id, 'kv-access')
scope: keyVault
properties: {
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'4633458b-17de-408a-b874-0445c86b69e6'
) // Key Vault Secrets User
principalId: webApp.identity.principalId
}
}
// Grant Managed Identity access to Storage
resource storageAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(webApp.identity.principalId, storageAccount.id, 'storage-access')
scope: storageAccount
properties: {
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
) // Storage Blob Data Contributor
principalId: webApp.identity.principalId
}
}
output appUrl string = 'https://${webApp.properties.defaultHostName}'
output keyVaultUri string = keyVault.properties.vaultUriDeploy:
# Get MongoDB connection string (you'll provide this)
MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net/mydatabase"
az deployment group create \
--name deploy-my-nextjs-app \
--resource-group rg-my-nextjs-app \
--template-file infra/main.bicep \
--parameters \
appName=my-nextjs-app \
location=eastus \
sku=B1 \
mongoDbConnectionString=$MONGODB_URI---
3. Next.js Configuration
next.config.js (Production)
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.azurewebsites.net',
port: '',
pathname: '/api/image/**',
},
{
protocol: 'https',
hostname: '**.blob.core.windows.net',
port: '',
pathname: '/recipe-images/**',
},
],
},
env: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'https://localhost:3000',
},
headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
];
},
};
module.exports = nextConfig;Key Vault Integration (lib/azure-keyvault.js)
// Install: npm install @azure/identity @azure/keyvault-secrets
import { DefaultAzureCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
const credential = new DefaultAzureCredential();
const keyVaultName = process.env.AZURE_KEYVAULT_NAME || 'my-nextjs-appkv';
const keyVaultUrl = `https://${keyVaultName}.vault.azure.net`;
const client = new SecretClient(keyVaultUrl, credential);
export async function getSecret(secretName) {
try {
const secret = await client.getSecret(secretName);
return secret.value;
} catch (error) {
console.error(`Error retrieving secret ${secretName}:`, error);
throw error;
}
}
// Preload secrets at startup
export async function loadAzureSecrets() {
try {
const [
mongoUri,
storageConn,
nextAuthSecret,
appInsightsKey,
] = await Promise.all([
getSecret('MongoDB-Uri'),
getSecret('Storage-ConnectionString'),
getSecret('NextAuth-Secret'),
getSecret('AppInsights-InstrumentationKey'),
]);
return {
MONGODB_URI: mongoUri,
AZURE_STORAGE_CONNECTION_STRING: storageConn,
NEXTAUTH_SECRET: nextAuthSecret,
APPLICATIONINSIGHTS_CONNECTION_STRING: `InstrumentationKey=${appInsightsKey}`,
};
} catch (error) {
console.error('Failed to load Azure secrets:', error);
throw error;
}
}lib/mongodb.js (with Key Vault)
import mongoose from 'mongoose';
import { loadAzureSecrets } from './azure-keyvault';
let isConnected = false;
export async function connectToDatabase() {
if (isConnected) {
return mongoose;
}
// Load MongoDB URI from Key Vault in production
const secrets = process.env.NODE_ENV === 'production'
? await loadAzureSecrets()
: {
MONGODB_URI: process.env.MONGODB_URI,
};
await mongoose.connect(secrets.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
isConnected = true;
console.log('Connected to MongoDB');
return mongoose;
}---
4. GitHub Actions CI/CD Pipeline
Set Repository Secrets
Go to GitHub > Repository > Settings > Secrets and variables > Actions and add:
| Secret Name | Value |
|---|---|
AZURE_SUBSCRIPTION_ID | Your Azure subscription ID |
AZURE_TENANT_ID | Your Azure AD tenant ID |
AZURE_CLIENT_ID | Service Principal client ID (for OIDC) |
Create Workflow
Create .github/workflows/deploy-appservice.yml:
name: Deploy Next.js to Azure App Service
on:
push:
branches: [main, staging]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
env:
NODE_VERSION: '20'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run lint --if-present
- run: npm run test --if-present
deploy-staging:
needs: test
if: github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ steps.deploy.outputs.webapp-url }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Azure Login (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
id: deploy
uses: azure/webapps-deploy@v3
with:
app-name: my-nextjs-app-staging
package: .
- name: Monitor deployment
run: |
echo "Staging URL: https://my-nextjs-app-staging.azurewebsites.net"
deploy-production:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: ${{ steps.deploy.outputs.webapp-url }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Azure Login (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 Production
id: deploy
uses: azure/webapps-deploy@v3
with:
app-name: my-nextjs-app
package: .
- name: Health check
run: |
sleep 30
curl -f https://my-nextjs-app.azurewebsites.net/api/health || exit 1
- name: Rollback on failure
if: failure()
uses: azure/webapps-deploy-action@v2
with:
app-name: my-nextjs-app
slot-name: staging---
5. Deployment Slots (Optional for Blue-Green)
Enable deployment slots for zero-downtime deployments:
# Enable deployment slots on App Service
az webapp deployment slot create \
--name my-nextjs-app \
--resource-group rg-my-nextjs-app \
--slot staging
# Configure staging slot settings
az webapp config appsettings set \
--name my-nextjs-app-slots-staging \
--resource-group rg-my-nextjs-app \
--settings \
NODE_ENV=staging \
NEXTAUTH_URL=https://my-nextjs-app-staging.azurewebsites.netUpdated workflow for slot deployment:
- name: Deploy to Staging Slot
uses: azure/webapps-deploy@v3
with:
app-name: my-nextjs-app
slot-name: staging
package: .
- name: Swap with Production
if: github.ref == 'refs/heads/main'
run: |
az webapp deployment slot swap \
--name my-nextjs-app \
--resource-group rg-my-nextjs-app \
--slot staging \
--target-slot production---
6. Monitoring with Application Insights
Install SDK
npm install @azure/monitor-opentelemetryInitialize Telemetry (lib/appinsights.js)
import { useAzureAppInsights } from '@azure/monitor-opentelemetry';
useAzureAppInsights({
connectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING,
});
export default {
trackEvent: (name, properties) => {
// Application Insights auto-tracks events
},
trackException: (error) => {
// Application Insights auto-tracks exceptions
},
trackDependency: (name, data) => {
// Application Insights auto-tracks dependencies
},
};Custom Metrics
// Track recipe views
import telemetry from './appinsights';
export async function recordRecipeView(recipeId, userId) {
telemetry.trackEvent('RecipeViewed', {
recipeId,
userId,
timestamp: new Date().toISOString(),
});
}
// Track user registration
export async function recordUserRegistration(userId, role) {
telemetry.trackEvent('UserRegistered', {
userId,
role,
timestamp: new Date().toISOString(),
});
}---
7. Azure Storage Integration for File Uploads
Upload Helper (lib/azure-storage.js)
// npm install @azure/storage-blob
import { BlobServiceClient } from '@azure/storage-blob';
import { getSecret } from './azure-keyvault';
let blobServiceClient;
async function getBlobServiceClient() {
if (!blobServiceClient) {
const connectionString = await getSecret('Storage-ConnectionString');
blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
}
return blobServiceClient;
}
export async function uploadRecipeImage(fileBuffer, fileName, contentType) {
const blobServiceClient = await getBlobServiceClient();
const containerClient = blobServiceClient.getContainerClient('recipe-images');
await containerClient.createIfNotExists({ access: 'blob' });
const blockBlobClient = containerClient.getBlockBlobClient(`${Date.now()}-${fileName}`);
await blockBlobClient.uploadData(fileBuffer, {
blobHTTPHeaders: { blobContentType: contentType },
metadata: {
uploadedAt: new Date().toISOString(),
},
});
return blockBlobClient.url;
}
export async function deleteRecipeImage(blobName) {
const blobServiceClient = await getBlobServiceClient();
const containerClient = blobServiceClient.getContainerClient('recipe-images');
const blockBlobClient = containerClient.getBlockBlobClient(blobName);
await blockBlobClient.deleteIfExists();
}
export async function generateImageUrl(blobName, expiresInMinutes = 60) {
const blobServiceClient = await getBlobServiceClient();
const containerClient = blobServiceClient.getContainerClient('recipe-images');
const blobClient = containerClient.getBlobClient(blobName);
const sasUrl = await blobClient.generateSasUrl({
permissions: { read: true },
expiresOn: new Date(Date.now() + expiresInMinutes * 60 * 1000),
});
return sasUrl;
}API Route for Image Upload (app/api/upload/route.js)
import { NextResponse } from 'next/server';
import { uploadRecipeImage } from '@/lib/azure-storage';
export async function POST(request) {
try {
const formData = await request.formData();
const file = formData.get('image');
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const fileName = file.name;
const contentType = file.type;
const imageUrl = await uploadRecipeImage(buffer, fileName, contentType);
return NextResponse.json({ imageUrl }, { status: 201 });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}---
8. Troubleshooting
Common Issues
Issue: "Error: Cannot find module './azure-keyvault'"
Cause: Missing Azure SDK packages Solution:
npm install @azure/identity @azure/keyvault-secretsIssue: Managed Identity cannot access Key Vault
Cause: Missing role assignment or firewall blocking Solution:
# Check role assignments
az role assignment list \
--assignee <PRINCIPAL_ID> \
--scope /subscriptions/<SUB_ID>/resourceGroups/<RG> \
--query [].[roleDefinitionName,principalId]
# Ensure Key Vault network allows trusted Microsoft services
az keyvault update \
--name $KEY_VAULT \
--resource-group $RG \
--default-action AllowIssue: Cold start latency (20-30 seconds)
Cause: App Service sleeping on free/basic tier Solution:
# Upgrade to Standard plan with Always On enabled
az appservice plan update \
--name $APP_SERVICE_PLAN \
--resource-group $RG \
--sku S1Issue: Image uploads failing with CORS error
Cause: CORS not configured on Storage Account Solution:
az storage cors clear \
--account-name $STORAGE_ACCOUNT \
--account-key <ACCOUNT_KEY>
az storage cors add \
--account-name $STORAGE_ACCOUNT \
--account-key <ACCOUNT_KEY> \
--services b \
--methods PUT GET DELETE OPTIONS \
--origins "https://my-nextjs-app.azurewebsites.net" \
--allowed-headers "*" \
--exposed-headers "*"Issue: Environment variables not loading in App Service
Cause: App Settings not configured or Key Vault Reference error Solution:
# Check current app settings
az webapp config appsettings list \
--name my-nextjs-app \
--resource-group rg-my-nextjs-app \
--query [].[name,value]
# Verify Managed Identity is enabled
az webapp identity show \
--name my-nextjs-app \
--resource-group rg-my-nextjs-app
# Test Key Vault access locally (requires Azure CLI login)
az keyvault secret show \
--name MongoDB-Uri \
--vault-name my-nextjs-appkvIssue: Application Insights not receiving telemetry
Cause: Missing connection string or SDK not initialized Solution:
// Verify connection string is set
console.log('App Insights Connection:', process.env.APPLICATIONINSIGHTS_CONNECTION_STRING?.substring(0, 20) + '...');
// Check telemetry in Azure Portal
# Navigate to: Application Insights > Logs
# Run query: traces | where timestamp > ago(1h) | project timestamp, message---
9. Best Practices
Security
- ✅ Always use Managed Identity (never store connection strings in app settings)
- ✅ Enable HTTPS Only on App Service
- ✅ Use Key Vault for all secrets and sensitive data
- ✅ Regularly rotate MongoDB Atlas credentials
- ✅ Enable App Service authentication (Azure AD, GitHub OAuth)
- ✅ Set up IP restrictions and VNet integration for production
Performance
- ✅ Use Always On for consistent response times (Standard+ tier)
- ✅ Configure scaling rules based on CPU/memory metrics
- ✅ Use CDN for serving static assets
- ✅ Optimize images before upload to reduce storage costs
Monitoring
- ✅ Set up alerts in Application Insights (response time, error rate, failed requests)
- ✅ Enable App Service diagnostic logs
- ✅ Configure log archiving to Azure Storage
- ✅ Create dashboards in Azure Monitor
Cost Optimization
- ✅ Use App Service Plan cost calculator for right-sizing
- ✅ Enable auto-scaling to manage peak vs. off-peak traffic
- ✅ Clean up old resources in non-production environments
- ✅ Use lifecycle policies for Storage Account cleanup
---
10. Deployment Checklist
Before deploying to production:
- [ ] All CI/CD tests passing
- [ ] Environment variables configured in Key Vault
- [ ] Managed Identity has correct role assignments
- [ ] Application Insights configured and receiving telemetry
- [ ] Storage Account CORS rules set up
- [ ] Custom domain configured (if applicable)
- [ ] SSL certificate installed for custom domain
- [ ] Deployment slots enabled for zero-downtime updates
- [ ] Backup strategy configured
- [ ] Monitoring alerts created
- [ ] Security best practices reviewed
---
Reference Documentation
End-to-End: Deploy a Vite + React App to Azure Static Web Apps
Complete walkthrough from zero to production with CI/CD, custom domain, and environment variables.
---
Prerequisites
- Node.js 20+ and npm/pnpm
- Azure CLI installed and logged in (
az login) - SWA CLI:
npm install -g @azure/static-web-apps-cli - A GitHub repository with your Vite + React project
- An Azure subscription (free tier works)
---
1. Project Structure
my-vite-app/
├── public/
│ └── favicon.svg
├── src/
│ ├── App.jsx
│ ├── main.jsx
│ └── index.css
├── staticwebapp.config.json ← SWA routing/headers config
├── .github/
│ └── workflows/
│ └── deploy-swa.yml ← CI/CD workflow
├── index.html
├── package.json
└── vite.config.js---
2. Azure Resource Setup
Option A: Azure CLI
# Variables
RG_NAME="rg-my-vite-app"
SWA_NAME="swa-my-vite-app"
LOCATION="centralus"
# Create resource group
az group create --name $RG_NAME --location $LOCATION
# Create Static Web App (Free tier)
az staticwebapp create \
--name $SWA_NAME \
--resource-group $RG_NAME \
--location $LOCATION \
--sku Free
# Get the deployment token (needed for GitHub Actions secret)
az staticwebapp secrets list \
--name $SWA_NAME \
--resource-group $RG_NAME \
--query "properties.apiKey" -o tsvSave the deployment token — you'll add it as a GitHub secret.
Option B: Bicep Template
Create infra/main.bicep:
targetScope = 'resourceGroup'
@description('Name of the Static Web App')
param appName string
@description('Azure region (SWA supports limited regions)')
@allowed(['centralus', 'eastus2', 'eastasia', 'westeurope', 'westus2'])
param location string = 'centralus'
@description('Pricing tier')
@allowed(['Free', 'Standard'])
param sku string = 'Free'
resource staticWebApp 'Microsoft.Web/staticSites@2023-12-01' = {
name: appName
location: location
sku: {
name: sku
tier: sku
}
properties: {
buildProperties: {
appLocation: '/'
outputLocation: 'dist'
}
}
}
resource appSettings 'Microsoft.Web/staticSites/config@2023-12-01' = {
parent: staticWebApp
name: 'appsettings'
properties: {
VITE_API_URL: 'https://api.example.com'
}
}
output defaultHostname string = staticWebApp.properties.defaultHostname
output swaId string = staticWebApp.idDeploy:
az deployment group create \
--resource-group rg-my-vite-app \
--template-file infra/main.bicep \
--parameters appName='swa-my-vite-app'---
3. Static Web App Configuration
Create staticwebapp.config.json in the project root:
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/assets/*", "/api/*"]
},
"routes": [
{
"route": "/api/*",
"allowedRoles": ["authenticated"]
},
{
"route": "/admin/*",
"allowedRoles": ["admin"]
},
{
"route": "/login",
"rewrite": "/.auth/login/github"
},
{
"route": "/logout",
"redirect": "/.auth/logout"
}
],
"responseOverrides": {
"401": {
"statusCode": 302,
"redirect": "/login"
},
"404": {
"rewrite": "/index.html",
"statusCode": 200
}
},
"globalHeaders": {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
},
"mimeTypes": {
".json": "application/json",
".wasm": "application/wasm"
},
"platform": {
"apiRuntime": "node:20"
}
}Key Config Points
- `navigationFallback`: Required for SPAs — rewrites all unmatched routes to
index.htmlso client-side routing (React Router, etc.) works. - `exclude`: Paths that should NOT be rewritten (static assets, API calls).
- `routes`: Role-based access control, redirects, rewrites.
- `globalHeaders`: Security headers applied to every response.
---
4. GitHub Actions Workflow
Set Repository Secret
Go to GitHub > Repository > Settings > Secrets and variables > Actions and add:
| Secret Name | Value |
|---|---|
AZURE_STATIC_WEB_APPS_API_TOKEN | The deployment token from Step 2 |
Create Workflow
Create .github/workflows/deploy-swa.yml:
name: Deploy to Azure Static Web Apps
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
jobs:
build-and-deploy:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint --if-present
- name: Run tests
run: npm run test -- --run --if-present
- name: Build
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || 'https://api.example.com' }}
- name: Deploy to SWA
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
skip_app_build: true # We already built above
app_location: "/"
output_location: "dist"
close-pull-request:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close PR Staging Environment
steps:
- name: Close staging
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: "close"What This Workflow Does
| Trigger | Action |
|---|---|
Push to main | Build + deploy to production |
| PR opened/updated | Build + deploy to a unique staging URL |
| PR closed | Tear down the staging environment |
Every pull request gets its own preview URL like https://lively-river-0a1b2c3d4-{PR_NUMBER}.centralus.azurestaticapps.net.
---
5. Environment Variables
Build-Time Variables (Vite)
Vite exposes variables prefixed with VITE_ to client code via import.meta.env.
In your React code:
const apiUrl = import.meta.env.VITE_API_URL;Set them in the workflow env block (see step 4) or in .env.production:
VITE_API_URL=https://api.example.com
VITE_APP_TITLE=My AppWarning: These are embedded into the JS bundle at build time and visible to the client. Never put secrets here.
Runtime Variables (SWA App Settings)
For backend/API environment variables (used by SWA managed functions):
az staticwebapp appsettings set \
--name swa-my-vite-app \
--resource-group rg-my-vite-app \
--setting-names \
"DATABASE_URL=mongodb+srv://..." \
"API_KEY=sk-..."These are server-side only and never exposed to the browser.
---
6. Custom Domain Setup
Add a Custom Domain
# Add the custom domain
az staticwebapp hostname set \
--name swa-my-vite-app \
--resource-group rg-my-vite-app \
--hostname www.example.comDNS Configuration
For Apex Domain (example.com)
| Type | Name | Value |
|---|---|---|
ALIAS or ANAME | @ | <your-swa>.azurestaticapps.net |
Not all DNS providers support ALIAS/ANAME for apex domains. If yours does not, use www and set up a redirect from apex.For Subdomain (www.example.com)
| Type | Name | Value |
|---|---|---|
CNAME | www | <your-swa>.azurestaticapps.net |
Verify and Enable SSL
# Check validation status (Azure provisions a free SSL certificate automatically)
az staticwebapp hostname list \
--name swa-my-vite-app \
--resource-group rg-my-vite-app \
--output tableSSL is automatically provisioned and renewed — no manual certificate management needed.
---
7. Local Development with SWA CLI
Test the full SWA experience locally including auth emulation and API routing:
# Start Vite dev server + SWA emulator
swa start http://localhost:5173 --run "npm run dev"
# Or with a local API
swa start http://localhost:5173 --api-location ./api --run "npm run dev"The SWA CLI emulator runs on http://localhost:4280 and provides:
- Auth simulation (
.auth/login/github,.auth/me) - Routing rules from
staticwebapp.config.json - Proxy to your Vite dev server and local API
---
8. Complete File Reference
package.json
{
"name": "my-vite-app",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint .",
"swa:start": "swa start http://localhost:5173 --run \"npm run dev\"",
"swa:deploy": "swa deploy dist --deployment-token $AZURE_STATIC_WEB_APPS_API_TOKEN"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.4.0",
"eslint": "^9.0.0",
"vite": "^6.0.0"
}
}vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: false,
},
server: {
port: 5173,
},
});.env.production
VITE_API_URL=https://api.example.com
VITE_APP_TITLE=My Vite App---
9. Troubleshooting
| Issue | Solution |
|---|---|
| 404 on page refresh | Add navigationFallback to staticwebapp.config.json (see step 3) |
| Assets not loading | Ensure exclude list in navigationFallback includes /assets/* |
| Custom domain not verifying | DNS propagation can take up to 48h; verify CNAME with dig www.example.com CNAME |
| Build fails in CI | Compare local Node version with CI — pin with .nvmrc or engines in package.json |
| Environment variables undefined | VITE_ prefix is required for client access; set them in the env: block of the build step |
| SWA CLI deploy fails locally | Ensure token is valid: re-run az staticwebapp secrets list |
| PR preview not appearing | Check that the PR targets the branch configured in the workflow trigger |
---
10. Cost Summary
| Tier | Monthly Cost | Features |
|---|---|---|
| Free | $0 | 2 custom domains, 0.5 GB storage, 100 GB bandwidth, built-in auth, PR preview environments |
| Standard | ~$9/month | 5 custom domains, 2 GB storage, 100 GB bandwidth, managed functions, SLA, private endpoints |
For most Vite + React SPAs, the Free tier is sufficient for production.
MIT License
Copyright (c) 2026 Sithu Win San
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bicep Template Quick Reference
Comprehensive reference for Azure Bicep — the declarative DSL for deploying Azure resources.
---
Fundamentals
Target Scope
targetScope = 'resourceGroup' // default
// Also: 'subscription', 'managementGroup', 'tenant'Parameters
@description('Name of the application')
@minLength(3)
@maxLength(24)
param appName string
@description('Deployment environment')
@allowed(['dev', 'staging', 'prod'])
param environment string = 'dev'
@secure()
param adminPassword string
param tags object = {
environment: environment
managedBy: 'bicep'
}
param allowedIPs array = []
param enableMonitoring bool = true
param instanceCount int = 1Variables
var resourcePrefix = '${appName}-${environment}'
var location = resourceGroup().location
var uniqueSuffix = uniqueString(resourceGroup().id)
var storageName = toLower('st${replace(resourcePrefix, '-', '')}${uniqueSuffix}')Outputs
output appUrl string = 'https://${webApp.properties.defaultHostName}'
output resourceId string = webApp.id
output storageEndpoint string = storageAccount.properties.primaryEndpoints.blob
// Typed object output
output connectionInfo object = {
host: webApp.properties.defaultHostName
resourceGroup: resourceGroup().name
}---
Resource Declarations
Basic Pattern
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
tags: tags
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
}
}Existing Keyword (Reference Pre-existing Resources)
resource existingVnet 'Microsoft.Network/virtualNetworks@2023-11-01' existing = {
name: 'my-existing-vnet'
scope: resourceGroup('networking-rg')
}
resource existingKeyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
name: keyVaultName
}Child Resources
// Inline child
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {}
resource blobService 'blobServices' = {
name: 'default'
resource container 'containers' = {
name: 'app-data'
properties: {
publicAccess: 'None'
}
}
}
}
// Separate declaration with parent
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
parent: storageAccount
name: 'default'
}---
Conditional Deployments
param deployRedis bool = false
param environment string
resource redisCache 'Microsoft.Cache/redis@2023-08-01' = if (deployRedis) {
name: '${resourcePrefix}-redis'
location: location
properties: {
sku: {
name: environment == 'prod' ? 'Premium' : 'Basic'
family: environment == 'prod' ? 'P' : 'C'
capacity: environment == 'prod' ? 1 : 0
}
enableNonSslPort: false
minimumTlsVersion: '1.2'
}
}
// Conditional output
output redisHostName string = deployRedis ? redisCache.properties.hostName : ''---
Loop Deployments
Array Loop
param storageNames array = ['logs', 'data', 'backups']
resource storageAccounts 'Microsoft.Storage/storageAccounts@2023-05-01' = [
for name in storageNames: {
name: 'st${name}${uniqueSuffix}'
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {}
}
]Index Loop
param appCount int = 3
resource webApps 'Microsoft.Web/sites@2023-12-01' = [
for i in range(0, appCount): {
name: '${resourcePrefix}-app-${i}'
location: location
properties: {
serverFarmId: appServicePlan.id
}
}
]Object Array Loop
param containers array = [
{ name: 'images', publicAccess: 'Blob' }
{ name: 'documents', publicAccess: 'None' }
{ name: 'logs', publicAccess: 'None' }
]
resource blobContainers 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = [
for container in containers: {
parent: blobService
name: container.name
properties: {
publicAccess: container.publicAccess
}
}
]Filtered Loop
param roleAssignments array = [
{ principalId: 'aaa', role: 'Reader' }
{ principalId: 'bbb', role: 'Contributor' }
{ principalId: 'ccc', role: 'Reader' }
]
resource readerAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
for assignment in filter(roleAssignments, r => r.role == 'Reader'): {
name: guid(resourceGroup().id, assignment.principalId, 'Reader')
properties: {
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'acdd72a7-3385-48ef-bd42-f606fba81ae7'
)
principalId: assignment.principalId
}
}
]---
Modules
Local Module
// main.bicep
module webAppModule './modules/webapp.bicep' = {
name: 'webAppDeployment'
params: {
appName: appName
location: location
appServicePlanId: appServicePlan.id
appSettings: {
NODE_ENV: 'production'
API_URL: apiUrl
}
}
}
output webAppUrl string = webAppModule.outputs.defaultHostName// modules/webapp.bicep
param appName string
param location string
param appServicePlanId string
param appSettings object = {}
var settingsArray = [
for item in items(appSettings): {
name: item.key
value: item.value
}
]
resource webApp 'Microsoft.Web/sites@2023-12-01' = {
name: appName
location: location
properties: {
serverFarmId: appServicePlanId
siteConfig: {
appSettings: settingsArray
linuxFxVersion: 'NODE|20-lts'
alwaysOn: true
}
httpsOnly: true
}
}
output defaultHostName string = webApp.properties.defaultHostName
output resourceId string = webApp.idModule with Condition and Loop
param regions array = ['eastus', 'westeurope']
param deployMultiRegion bool = false
module regionalApps './modules/webapp.bicep' = [
for (region, i) in regions: if (deployMultiRegion || i == 0) {
name: 'deploy-${region}'
params: {
appName: '${appName}-${region}'
location: region
appServicePlanId: plans[i].outputs.planId
}
}
]Cross-Resource-Group Module
module sharedResources './modules/shared.bicep' = {
name: 'sharedResourcesDeploy'
scope: resourceGroup('shared-resources-rg')
params: {
keyVaultName: 'kv-${appName}'
}
}---
Common Resource Patterns
App Service Plan + Web App (Linux, Node.js)
param appName string
param location string = resourceGroup().location
param skuName string = 'B1'
resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: '${appName}-plan'
location: location
kind: 'linux'
sku: {
name: skuName
}
properties: {
reserved: true // required for Linux
}
}
resource webApp 'Microsoft.Web/sites@2023-12-01' = {
name: appName
location: location
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'NODE|20-lts'
alwaysOn: skuName != 'F1'
ftpsState: 'Disabled'
minTlsVersion: '1.2'
http20Enabled: true
appSettings: [
{ name: 'WEBSITE_NODE_DEFAULT_VERSION', value: '~20' }
{ name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' }
]
}
}
}
// Deployment slot for staging
resource stagingSlot 'Microsoft.Web/sites/slots@2023-12-01' = {
parent: webApp
name: 'staging'
location: location
properties: {
serverFarmId: appServicePlan.id
siteConfig: {
linuxFxVersion: 'NODE|20-lts'
autoSwapSlotName: 'production'
}
}
}Storage Account with Blob Containers
param storageName string
param location string = resourceGroup().location
param containerNames array = ['uploads', 'static', 'backups']
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
networkAcls: {
defaultAction: 'Deny'
bypass: 'AzureServices'
}
}
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
parent: storageAccount
name: 'default'
properties: {
deleteRetentionPolicy: {
enabled: true
days: 7
}
}
}
resource containers 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = [
for name in containerNames: {
parent: blobService
name: name
properties: {
publicAccess: 'None'
}
}
]
output storageId string = storageAccount.id
output blobEndpoint string = storageAccount.properties.primaryEndpoints.blob
output connectionString string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value}'Azure Static Web Apps
param appName string
param location string = 'centralus'
param sku string = 'Free' // 'Free' or 'Standard'
param repositoryUrl string = ''
param branch string = 'main'
param appLocation string = '/'
param outputLocation string = 'dist'
param apiLocation string = ''
resource staticWebApp 'Microsoft.Web/staticSites@2023-12-01' = {
name: appName
location: location
sku: {
name: sku
tier: sku
}
properties: {
repositoryUrl: !empty(repositoryUrl) ? repositoryUrl : null
branch: !empty(repositoryUrl) ? branch : null
buildProperties: {
appLocation: appLocation
outputLocation: outputLocation
apiLocation: !empty(apiLocation) ? apiLocation : null
}
}
}
// App settings
resource swaAppSettings 'Microsoft.Web/staticSites/config@2023-12-01' = {
parent: staticWebApp
name: 'appsettings'
properties: {
API_URL: 'https://api.example.com'
ENVIRONMENT: 'production'
}
}
output swaUrl string = 'https://${staticWebApp.properties.defaultHostname}'
output swaId string = staticWebApp.id
output deploymentToken string = staticWebApp.listSecrets().properties.apiKeyCosmos DB (NoSQL API)
param accountName string
param location string = resourceGroup().location
param databaseName string = 'appdb'
param containerConfigs array = [
{ name: 'users', partitionKey: '/userId', throughput: 400 }
{ name: 'recipes', partitionKey: '/category', throughput: 400 }
]
resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = {
name: accountName
location: location
kind: 'GlobalDocumentDB'
properties: {
databaseAccountOfferType: 'Standard'
consistencyPolicy: {
defaultConsistencyLevel: 'Session'
}
locations: [
{
locationName: location
failoverPriority: 0
isZoneRedundant: false
}
]
capabilities: [
{ name: 'EnableServerless' }
]
backupPolicy: {
type: 'Continuous'
continuousModeProperties: {
tier: 'Continuous7Days'
}
}
}
}
resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-05-15' = {
parent: cosmosAccount
name: databaseName
properties: {
resource: {
id: databaseName
}
}
}
resource containers 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-05-15' = [
for config in containerConfigs: {
parent: database
name: config.name
properties: {
resource: {
id: config.name
partitionKey: {
paths: [config.partitionKey]
kind: 'Hash'
}
indexingPolicy: {
automatic: true
indexingMode: 'consistent'
}
}
}
}
]
output cosmosEndpoint string = cosmosAccount.properties.documentEndpoint
output cosmosAccountName string = cosmosAccount.name---
User-Defined Types (Bicep v0.21+)
@description('Configuration for an application environment')
type environmentConfig = {
@description('Environment name')
name: 'dev' | 'staging' | 'prod'
@description('SKU tier')
sku: string
@description('Number of instances')
instanceCount: int
@description('Custom domain (optional)')
customDomain: string?
}
param envConfig environmentConfig = {
name: 'dev'
sku: 'B1'
instanceCount: 1
}---
Decorators Reference
| Decorator | Applies To | Purpose |
|---|---|---|
@description() | param, output, type | Describes the element |
@secure() | param | Marks as sensitive (no logging) |
@allowed([]) | param | Restricts to listed values |
@minLength() / @maxLength() | param (string/array) | Length constraints |
@minValue() / @maxValue() | param (int) | Numeric range |
@metadata({}) | param | Arbitrary metadata |
@sealed() | type, param | Prevents additional properties |
@discriminator() | type | Tagged union discriminator |
---
Useful Built-in Functions
| Function | Example | Returns |
|---|---|---|
resourceGroup().location | — | Resource group's region |
uniqueString(seed) | uniqueString(resourceGroup().id) | 13-char deterministic hash |
subscription().subscriptionId | — | Current subscription ID |
tenant().tenantId | — | Current tenant ID |
environment().suffixes.storage | — | Storage endpoint suffix |
toLower() / toUpper() | toLower('ABC') | abc |
replace() | replace('a-b', '-', '') | ab |
guid() | guid(resourceGroup().id, 'reader') | Deterministic GUID |
loadTextContent() | loadTextContent('./script.sh') | File contents as string |
loadJsonContent() | loadJsonContent('./config.json') | Parsed JSON object |
---
CLI Commands
# Validate template
az bicep build --file main.bicep
# Deploy to resource group
az deployment group create \
--resource-group myRg \
--template-file main.bicep \
--parameters environment='prod' appName='myapp'
# Deploy with parameter file
az deployment group create \
--resource-group myRg \
--template-file main.bicep \
--parameters @parameters.prod.json
# What-if (preview changes)
az deployment group what-if \
--resource-group myRg \
--template-file main.bicep \
--parameters @parameters.prod.json
# Subscription-level deployment
az deployment sub create \
--location eastus \
--template-file main.bicepGitHub Actions for Azure Deployment Patterns
Workflows, actions, and patterns for deploying to Azure services from GitHub Actions.
---
Authentication
OIDC Federated Credentials (Recommended)
No secrets stored in GitHub — uses short-lived tokens via Azure AD workload identity federation.
1. Create Azure AD App Registration + Federated Credential
# Create app registration
az ad app create --display-name "github-deploy-myapp"
APP_ID=$(az ad app list --display-name "github-deploy-myapp" --query "[0].appId" -o tsv)
OBJECT_ID=$(az ad app list --display-name "github-deploy-myapp" --query "[0].id" -o tsv)
# Create service principal
az ad sp create --id $APP_ID
SP_OBJECT_ID=$(az ad sp list --filter "appId eq '$APP_ID'" --query "[0].id" -o tsv)
# Assign Contributor role on resource group
az role assignment create \
--assignee $SP_OBJECT_ID \
--role "Contributor" \
--scope "/subscriptions/<SUB_ID>/resourceGroups/<RG_NAME>"
# Add federated credential for GitHub Actions (main branch)
az ad app federated-credential create --id $OBJECT_ID --parameters '{
"name": "github-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:<OWNER>/<REPO>:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# Add federated credential for pull requests (optional)
az ad app federated-credential create --id $OBJECT_ID --parameters '{
"name": "github-pr",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:<OWNER>/<REPO>:pull_request",
"audiences": ["api://AzureADTokenExchange"]
}'
# Add federated credential for an environment
az ad app federated-credential create --id $OBJECT_ID --parameters '{
"name": "github-production",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:<OWNER>/<REPO>:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'2. GitHub Repository Secrets
Set these in Settings > Secrets and variables > Actions:
| Secret | Value |
|---|---|
AZURE_CLIENT_ID | App registration Application (client) ID |
AZURE_TENANT_ID | Azure AD tenant ID |
AZURE_SUBSCRIPTION_ID | Target subscription ID |
3. Workflow Login Step (OIDC)
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}Service Principal with Secret (Legacy)
steps:
- name: Azure Login
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# AZURE_CREDENTIALS is a JSON object:
# {
# "clientId": "...",
# "clientSecret": "...",
# "subscriptionId": "...",
# "tenantId": "..."
# }---
Azure Static Web Apps
Deploy with SWA CLI (Vite / React / Next.js)
name: Deploy Static Web App
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
jobs:
build-and-deploy:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install and Build
run: |
npm ci
npm run build
- name: Deploy to Azure Static Web Apps
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "/"
output_location: "dist"
# api_location: "api" # Uncomment if using managed API
close-pr:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close PR Environment
steps:
- name: Close staging environment
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: "close"Deploy Next.js to Static Web Apps (Hybrid)
name: Deploy Next.js to SWA
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Deploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "/"
output_location: ".next"
# Next.js hybrid rendering on SWA requires Standard plan---
Azure App Service (Node.js)
Basic Deployment
name: Deploy to App Service
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AZURE_WEBAPP_NAME: my-node-app
NODE_VERSION: '20'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install and build
run: |
npm ci
npm run build --if-present
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: node-app
path: .
include-hidden-files: true
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: production
url: ${{ steps.deploy.outputs.webapp-url }}
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: node-app
- name: Azure Login (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 App Service
id: deploy
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}Deployment Slots (Blue-Green)
name: Deploy with Staging Slot
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AZURE_WEBAPP_NAME: my-node-app
SLOT_NAME: staging
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci && npm run build --if-present
- uses: actions/upload-artifact@v4
with:
name: app
path: .
include-hidden-files: true
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ steps.deploy.outputs.webapp-url }}
steps:
- uses: actions/download-artifact@v4
with:
name: app
- 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
id: deploy
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
slot-name: ${{ env.SLOT_NAME }}
- name: Smoke test staging
run: |
STAGING_URL="https://${{ env.AZURE_WEBAPP_NAME }}-${{ env.SLOT_NAME }}.azurewebsites.net"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$STAGING_URL/health")
if [ "$STATUS" != "200" ]; then
echo "Smoke test failed with status $STATUS"
exit 1
fi
echo "Smoke test passed"
swap-to-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
steps:
- 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 to production
run: |
az webapp deployment slot swap \
--name ${{ env.AZURE_WEBAPP_NAME }} \
--resource-group ${{ vars.AZURE_RG }} \
--slot ${{ env.SLOT_NAME }} \
--target-slot production---
Azure Functions
Deploy Node.js Function App
name: Deploy Azure Functions
on:
push:
branches: [main]
paths:
- 'api/**'
permissions:
id-token: write
contents: read
env:
FUNCTION_APP_NAME: my-functions
NODE_VERSION: '20'
PACKAGE_PATH: 'api'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: '${{ env.PACKAGE_PATH }}/package-lock.json'
- name: Install and build
working-directory: ${{ env.PACKAGE_PATH }}
run: |
npm ci
npm run build --if-present
- 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 Azure Functions
uses: Azure/functions-action@v1
with:
app-name: ${{ env.FUNCTION_APP_NAME }}
package: ${{ env.PACKAGE_PATH }}---
Multi-Stage Deployment Pipeline
Full CI/CD: Test → Build → Deploy Staging → Deploy Production
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
permissions:
id-token: write
contents: read
checks: write
pull-requests: write
env:
NODE_VERSION: '20'
AZURE_WEBAPP_NAME: my-app
jobs:
# ─── Stage 1: Lint + Test ───
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test -- --coverage
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
# ─── Stage 2: Build ───
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: |
dist/
package.json
package-lock.json
retention-days: 3
# ─── Stage 3: Deploy to Staging (develop and main) ───
deploy-staging:
needs: build
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment:
name: staging
url: https://${{ env.AZURE_WEBAPP_NAME }}-staging.azurewebsites.net
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
slot-name: staging
# ─── Stage 4: Deploy to Production (main only) ───
deploy-production:
needs: deploy-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net
steps:
- 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 → production
run: |
az webapp deployment slot swap \
--name ${{ env.AZURE_WEBAPP_NAME }} \
--resource-group ${{ vars.AZURE_RG }} \
--slot staging \
--target-slot production---
Infrastructure as Code Deployment
Deploy Bicep Templates
name: Deploy Infrastructure
on:
push:
branches: [main]
paths:
- 'infra/**'
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'dev'
type: choice
options: [dev, staging, prod]
permissions:
id-token: write
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Validate Bicep
run: az bicep build --file infra/main.bicep
- name: What-if
run: |
az deployment group what-if \
--resource-group ${{ vars.AZURE_RG }} \
--template-file infra/main.bicep \
--parameters infra/parameters.${{ inputs.environment || 'dev' }}.json
deploy:
needs: validate
runs-on: ubuntu-latest
environment: ${{ inputs.environment || 'dev' }}
steps:
- uses: actions/checkout@v4
- 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 infrastructure
uses: azure/arm-deploy@v2
with:
resourceGroupName: ${{ vars.AZURE_RG }}
template: infra/main.bicep
parameters: infra/parameters.${{ inputs.environment || 'dev' }}.json
failOnStdErr: false---
Environment Secrets and Variables
Organization-Level (Shared)
Set in Organization Settings > Secrets and variables > Actions:
AZURE_TENANT_ID— Shared across all repos in the orgAZURE_SUBSCRIPTION_ID— Shared subscription
Repository-Level
Set in Repository Settings > Secrets and variables > Actions:
AZURE_CLIENT_ID— Per-repo app registrationAZURE_STATIC_WEB_APPS_API_TOKEN— SWA deployment token
Environment-Level
Set in Repository Settings > Environments > (env name) > Secrets:
- Per-environment overrides (e.g., different
AZURE_CLIENT_IDper environment) - Protection rules: required reviewers, wait timer, branch restrictions
Using Variables (Non-Sensitive)
# Repository or environment variables (not secrets)
env:
AZURE_RG: ${{ vars.AZURE_RG }} # from vars, not secrets
AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}---
Reusable Workflows
Callable Deploy Workflow
# .github/workflows/deploy-reusable.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
app-name:
required: true
type: string
slot-name:
required: false
type: string
default: 'production'
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: ${{ inputs.app-name }}
slot-name: ${{ inputs.slot-name }}Calling Reusable Workflow
# .github/workflows/ci-cd.yml
jobs:
build:
# ... build steps ...
deploy-staging:
needs: build
uses: ./.github/workflows/deploy-reusable.yml
with:
environment: staging
app-name: my-app
slot-name: staging
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/deploy-reusable.yml
with:
environment: production
app-name: my-app
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}---
Key GitHub Actions for Azure
| Action | Version | Purpose |
|---|---|---|
azure/login | v2 | Authenticate to Azure (OIDC or SP) |
azure/webapps-deploy | v3 | Deploy to App Service |
azure/functions-action | v1 | Deploy to Azure Functions |
Azure/static-web-apps-deploy | v1 | Deploy to Static Web Apps |
azure/arm-deploy | v2 | Deploy Bicep/ARM templates |
azure/cli | v2 | Run arbitrary Azure CLI commands |
azure/docker-login | v2 | Login to ACR |
azure/container-apps-deploy-action | v1 | Deploy to Container Apps |
<#
.SYNOPSIS
Build and deploy a Node or Next.js app to Azure App Service with zip deployment.
.DESCRIPTION
Creates the resource group, Linux App Service plan, and web app if they do not exist.
Builds the app unless -SkipBuild is provided, zips the chosen output directory, and deploys it.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$ResourceGroup,
[string]$Location = "eastus",
[string]$PlanName,
[string]$Runtime = "NODE|20-lts",
[string]$AppDir = ".",
[string]$OutputDir = "dist",
[switch]$SkipBuild
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Assert-Command {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command '$Name' was not found in PATH."
}
}
function Ensure-AzureLogin {
az account show *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "Azure CLI is not logged in. Launching 'az login'..." -ForegroundColor Yellow
az login | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Azure login failed."
}
}
}
function Ensure-ResourceGroup {
$exists = az group exists --name $ResourceGroup
if ($exists -eq "false") {
Write-Host "Creating resource group '$ResourceGroup' in '$Location'..." -ForegroundColor Cyan
az group create --name $ResourceGroup --location $Location --output none
}
}
function Ensure-Plan {
param([string]$ResolvedPlanName)
az appservice plan show --name $ResolvedPlanName --resource-group $ResourceGroup *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "Creating App Service plan '$ResolvedPlanName'..." -ForegroundColor Cyan
az appservice plan create `
--name $ResolvedPlanName `
--resource-group $ResourceGroup `
--location $Location `
--is-linux `
--sku B1 `
--output none
}
}
function Ensure-WebApp {
param([string]$ResolvedPlanName)
az webapp show --name $AppName --resource-group $ResourceGroup *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "Creating web app '$AppName'..." -ForegroundColor Cyan
az webapp create `
--name $AppName `
--resource-group $ResourceGroup `
--plan $ResolvedPlanName `
--runtime $Runtime `
--output none
}
}
function Invoke-Build {
Push-Location $AppDir
try {
if ($SkipBuild) {
return
}
Assert-Command node
$pm = if (Test-Path "pnpm-lock.yaml") { "pnpm" } elseif (Test-Path "yarn.lock") { "yarn" } else { "npm" }
Write-Host "Installing dependencies with $pm..." -ForegroundColor Cyan
& $pm install
if ($LASTEXITCODE -ne 0) {
throw "Dependency installation failed."
}
Write-Host "Building application..." -ForegroundColor Cyan
& $pm run build
if ($LASTEXITCODE -ne 0) {
throw "Build failed."
}
}
finally {
Pop-Location
}
}
function Publish-ZipDeployment {
Push-Location $AppDir
try {
if (-not (Test-Path $OutputDir)) {
throw "Output directory '$OutputDir' was not found."
}
$zipPath = Join-Path ([System.IO.Path]::GetTempPath()) "$AppName-deploy.zip"
if (Test-Path $zipPath) {
Remove-Item -Force $zipPath
}
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $zipPath -Force
Write-Host "Deploying archive to Azure App Service..." -ForegroundColor Cyan
az webapp deployment source config-zip `
--name $AppName `
--resource-group $ResourceGroup `
--src $zipPath `
--output none
Remove-Item -Force $zipPath
}
finally {
Pop-Location
}
}
Assert-Command az
Ensure-AzureLogin
$resolvedPlan = if ($PlanName) { $PlanName } else { "$AppName-plan" }
Ensure-ResourceGroup
Ensure-Plan -ResolvedPlanName $resolvedPlan
Ensure-WebApp -ResolvedPlanName $resolvedPlan
Invoke-Build
Publish-ZipDeployment
Write-Host ""
Write-Host "Deployment complete." -ForegroundColor Green
Write-Host "App URL: https://$AppName.azurewebsites.net" -ForegroundColor Green
<#
.SYNOPSIS
Deploys a Vite/React or Next.js application to Azure Static Web Apps.
.DESCRIPTION
Creates (or reuses) a resource group and Azure Static Web App resource,
then deploys the built application using the SWA CLI.
.PARAMETER AppName
Name of the Static Web App resource. Must be globally unique.
.PARAMETER ResourceGroup
Name of the Azure resource group. Created if it does not exist.
.PARAMETER Location
Azure region for the resource group and SWA. Default: centralus.
.PARAMETER OutputDir
Build output directory relative to the project root (e.g., "dist" for Vite, ".next" for Next.js).
.PARAMETER AppDir
Application source directory. Default: current directory.
.PARAMETER ApiDir
Optional managed API directory (e.g., "api").
.PARAMETER Sku
SWA pricing tier: Free or Standard. Default: Free.
.PARAMETER SkipBuild
If set, skips the npm build step (expects output already present).
.EXAMPLE
.\deploy-swa.ps1 -AppName "my-vite-app" -ResourceGroup "rg-my-app" -OutputDir "dist"
.EXAMPLE
.\deploy-swa.ps1 -AppName "my-next-app" -ResourceGroup "rg-next" -OutputDir ".next" -Sku Standard
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9-]{1,58}[a-zA-Z0-9]$')]
[string]$AppName,
[Parameter(Mandatory)]
[string]$ResourceGroup,
[string]$Location = 'centralus',
[Parameter(Mandatory)]
[string]$OutputDir,
[string]$AppDir = '.',
[string]$ApiDir = '',
[ValidateSet('Free', 'Standard')]
[string]$Sku = 'Free',
[switch]$SkipBuild
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Step {
param([string]$Message)
Write-Host "`n>> $Message" -ForegroundColor Cyan
}
function Assert-Command {
param([string]$Name, [string]$InstallHint)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
Write-Error "$Name is not installed or not in PATH. $InstallHint"
exit 1
}
}
# ─── Prerequisites ───
Write-Step 'Checking prerequisites'
Assert-Command 'az' 'Install Azure CLI: https://aka.ms/install-azure-cli'
Assert-Command 'swa' 'Install SWA CLI: npm install -g @azure/static-web-apps-cli'
Assert-Command 'node' 'Install Node.js: https://nodejs.org'
# ─── Azure Login Check ───
Write-Step 'Verifying Azure CLI login'
$account = az account show 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host 'Not logged in. Launching interactive login...' -ForegroundColor Yellow
az login
if ($LASTEXITCODE -ne 0) {
Write-Error 'Azure login failed.'
exit 1
}
}
$currentAccount = az account show --query '{subscription:name, tenantId:tenantId}' -o json | ConvertFrom-Json
Write-Host " Subscription: $($currentAccount.subscription)"
Write-Host " Tenant: $($currentAccount.tenantId)"
# ─── Resource Group ───
Write-Step "Ensuring resource group '$ResourceGroup' exists in '$Location'"
$rgExists = az group exists --name $ResourceGroup
if ($rgExists -eq 'false') {
Write-Host " Creating resource group..." -ForegroundColor Yellow
az group create --name $ResourceGroup --location $Location --output none
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create resource group '$ResourceGroup'."
exit 1
}
Write-Host " Resource group created." -ForegroundColor Green
} else {
Write-Host " Resource group already exists." -ForegroundColor Green
}
# ─── Static Web App Resource ───
Write-Step "Ensuring Static Web App '$AppName' exists"
$swaExists = az staticwebapp show --name $AppName --resource-group $ResourceGroup 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " Creating Static Web App ($Sku tier)..." -ForegroundColor Yellow
az staticwebapp create `
--name $AppName `
--resource-group $ResourceGroup `
--location $Location `
--sku $Sku `
--output none
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create Static Web App '$AppName'."
exit 1
}
Write-Host " Static Web App created." -ForegroundColor Green
} else {
Write-Host " Static Web App already exists." -ForegroundColor Green
}
# ─── Retrieve Deployment Token ───
Write-Step 'Retrieving deployment token'
$deploymentToken = az staticwebapp secrets list `
--name $AppName `
--resource-group $ResourceGroup `
--query 'properties.apiKey' -o tsv
if (-not $deploymentToken) {
Write-Error 'Failed to retrieve SWA deployment token.'
exit 1
}
Write-Host ' Deployment token acquired.' -ForegroundColor Green
# ─── Build Application ───
Push-Location $AppDir
try {
if (-not $SkipBuild) {
Write-Step 'Building application'
$packageManager = 'npm'
if (Test-Path 'pnpm-lock.yaml') { $packageManager = 'pnpm' }
elseif (Test-Path 'yarn.lock') { $packageManager = 'yarn' }
Write-Host " Detected package manager: $packageManager"
& $packageManager install
if ($LASTEXITCODE -ne 0) {
Write-Error 'Dependency installation failed.'
exit 1
}
& $packageManager run build
if ($LASTEXITCODE -ne 0) {
Write-Error 'Build failed.'
exit 1
}
Write-Host ' Build succeeded.' -ForegroundColor Green
} else {
Write-Host ' Skipping build (--SkipBuild).' -ForegroundColor Yellow
}
if (-not (Test-Path $OutputDir)) {
Write-Error "Build output directory '$OutputDir' not found. Did the build succeed?"
exit 1
}
# ─── Deploy ───
Write-Step "Deploying to Azure Static Web Apps"
$swaArgs = @(
'deploy'
'--output-location', $OutputDir
'--deployment-token', $deploymentToken
'--env', 'production'
)
if ($ApiDir -and (Test-Path $ApiDir)) {
$swaArgs += '--api-location', $ApiDir
}
& swa @swaArgs
if ($LASTEXITCODE -ne 0) {
Write-Error 'SWA deployment failed.'
exit 1
}
# ─── Summary ───
$hostname = az staticwebapp show `
--name $AppName `
--resource-group $ResourceGroup `
--query 'defaultHostname' -o tsv
Write-Host ''
Write-Host '==========================================' -ForegroundColor Green
Write-Host ' Deployment Successful!' -ForegroundColor Green
Write-Host " URL: https://$hostname" -ForegroundColor Green
Write-Host " Resource Group: $ResourceGroup" -ForegroundColor Green
Write-Host " SWA Name: $AppName" -ForegroundColor Green
Write-Host " SKU: $Sku" -ForegroundColor Green
Write-Host '==========================================' -ForegroundColor Green
}
finally {
Pop-Location
}
Related skills
FAQ
What does azure-integrations do?
azure-integrations is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted development.
When should I use azure-integrations?
When you need to helps with ai & agent building tasks, or when azure-integrations is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted development.
What are the main capabilities?
azure-integrations; AI & Agent Building; AI-coding skill.