Author: Austin Dennis — LinkedIn
A PowerShell script that stands up a full Azure development environment using free or near-free resources, wired together with managed identity and Entra authentication. Designed for standard web projects — a static frontend talking to a serverless API backed by Azure SQL.
| Resource | Tier | Purpose |
|---|---|---|
| Static Web App | Free | Frontend hosting |
| Function App | Flex Consumption (Node.js 24, falls back to 22 or 20 if region doesn't support 24) | Backend API |
| SQL Server + SQL Database | Free tier (useFreeLimit=true) |
Database (GP_S_Gen5 serverless, auto-pause 60 min) |
| Key Vault | Standard (RBAC-enabled) | Secret storage |
| Application Insights | Workspace-backed | Function App telemetry |
| Log Analytics | Pay-per-GB | App Insights backing + SQL diagnostics |
| Storage Account | Standard_LRS | Function App runtime storage + deployment container |
Security posture:
- Function App uses a system-assigned managed identity — no connection strings
- SQL Server uses Entra-only authentication — no SQL login exists
- Key Vault uses RBAC authorization with an explicit empty access policy list
- Function App deployment storage authenticated via managed identity
- Function App CORS pre-configured to allow only the Static Web App origin plus
localhost:3000,5173, and4200(withsupportCredentials=true)
- Azure subscription with Owner or Contributor + User Access Administrator roles
- PowerShell 5.1+ or 7+ (works in Azure Cloud Shell too)
- Az PowerShell module (
Install-Module Az -Scope CurrentUser)
.\Deploy-AzureDevEnvironment.ps1No parameters. The script will prompt you for:
- Azure login (skipped in Cloud Shell)
- Subscription
- Resource group name
- Region
It registers required resource providers, creates everything in dependency order, and configures RBAC + SQL database user automatically.
Total time: ~5–8 minutes.
Add secrets to your Key Vault — your Function App's managed identity already has Key Vault Secrets User role:
# Set a secret
$secretValue = ConvertTo-SecureString "my-api-key-value" -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName "<kv-name>" -Name "MyApiKey" -SecretValue $secretValue
# Or via Azure CLI
az keyvault secret set --vault-name <kv-name> --name MyApiKey --value "my-api-key-value"You'll need Key Vault Secrets Officer role on the vault to write secrets. Grant it to yourself once:
$me = (Get-AzADUser -SignedIn).Id
$kvId = (Get-AzKeyVault -VaultName "<kv-name>").ResourceId
New-AzRoleAssignment -ObjectId $me `
-RoleDefinitionName "Key Vault Secrets Officer" `
-Scope $kvIdUse Key Vault references in Function App settings. Azure automatically resolves them at runtime using the managed identity — you never see or handle the secret value in code.
Format:
@Microsoft.KeyVault(VaultName=<kv-name>;SecretName=<secret-name>)
Set an app setting that references a Key Vault secret:
# Azure CLI
az functionapp config appsettings set \
--name <function-app-name> \
--resource-group <rg-name> \
--settings "MY_API_KEY=@Microsoft.KeyVault(VaultName=<kv-name>;SecretName=MyApiKey)"In your function code, just read it as a normal environment variable:
// Node.js
const apiKey = process.env.MY_API_KEY;// C#
var apiKey = Environment.GetEnvironmentVariable("MY_API_KEY");Verify the reference resolved correctly in the portal: Function App → Environment variables → App settings. A healthy reference shows a green checkmark. A red X means the identity can't access the secret (check RBAC) or the secret doesn't exist.
The Function App's managed identity already has db_datareader and db_datawriter on the database. Connect from your code using an Entra token — no connection string secrets:
Node.js (using mssql + @azure/identity):
const sql = require('mssql');
const { DefaultAzureCredential } = require('@azure/identity');
const credential = new DefaultAzureCredential();
const token = await credential.getToken('https://database.windows.net/.default');
const pool = await sql.connect({
server: '<sql-server-name>.database.windows.net',
database: '<db-name>',
authentication: {
type: 'azure-active-directory-access-token',
options: { token: token.token }
},
options: { encrypt: true }
});C# (using Microsoft.Data.SqlClient):
using Microsoft.Data.SqlClient;
var connectionString =
"Server=tcp:<sql-server-name>.database.windows.net,1433;" +
"Database=<db-name>;" +
"Authentication=Active Directory Default;" +
"Encrypt=True;";
using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();DefaultAzureCredential / Active Directory Default automatically use the managed identity when running in Azure, and your developer credentials when running locally.
For local development, the SQL firewall rule ClientDeploymentIP was added for your machine, and the signed-in Entra user is the server administrator — so you can connect from SSMS, Azure Data Studio, or your dev machine directly.
Option A — GitHub Actions (recommended):
- Azure Portal → your Static Web App → Overview → Manage deployment token — copy the token
- In your GitHub repo settings, add the token as a secret named
AZURE_STATIC_WEB_APPS_API_TOKEN - Add a workflow at
.github/workflows/azure-static-web-apps.yml:
name: Deploy Static Web App
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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: "/" # path to frontend source
api_location: "" # leave empty, we use separate Function App
output_location: "dist" # build output folderOption B — SWA CLI (local):
npm install -g @azure/static-web-apps-cli
swa deploy ./dist --deployment-token <token>Option A — VS Code Azure Functions extension (easiest):
- Install the Azure Functions extension
- Sign in to Azure in VS Code
- Right-click your function project → Deploy to Function App → select your app
Option B — Azure Functions Core Tools (func CLI):
cd /path/to/function-project
func azure functionapp publish <function-app-name>Option C — GitHub Actions:
Use the Deployment Center in the portal (Function App → Deployment Center → GitHub) to scaffold a workflow with a pre-configured publish profile secret. Recommended for production flows.
Note on Flex Consumption: Deployment uses the deployments blob container in your storage account, authenticated via the Function App's managed identity. You don't need to manage a deployment connection string.
$func = Get-AzFunctionApp -ResourceGroupName "<rg>" -Name "<function-app-name>"
$principalId = $func.IdentityPrincipalId
# Example: grant Cosmos DB data access
New-AzRoleAssignment `
-ObjectId $principalId `
-RoleDefinitionName "DocumentDB Account Contributor" `
-Scope "<cosmos-db-resource-id>"Common built-in roles you may need:
| Role | Use for |
|---|---|
Storage Blob Data Contributor |
Read/write blobs (already assigned) |
Storage Queue Data Contributor |
Use queue triggers/bindings (already assigned) |
Storage Table Data Contributor |
Use table storage |
Service Bus Data Sender/Receiver |
Service Bus triggers/bindings |
Event Hubs Data Receiver/Sender |
Event Hubs triggers/bindings |
Cognitive Services User |
Call Azure AI/OpenAI services |
Connect to the database as the Entra admin (you) and run:
-- Additional permissions on specific tables
GRANT SELECT ON dbo.MyTable TO [<function-app-name>];
-- Execute stored procedures
GRANT EXECUTE ON SCHEMA::dbo TO [<function-app-name>];
-- Full access role
ALTER ROLE db_owner ADD MEMBER [<function-app-name>]; -- use sparinglyCREATE USER [user@domain.com] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [user@domain.com];For SQL Server admin rights, update the Entra admin in the portal (SQL Server → Microsoft Entra ID) or via:
Set-AzSqlServerActiveDirectoryAdministrator `
-ResourceGroupName "<rg>" `
-ServerName "<sql-server-name>" `
-DisplayName "<user-display-name>"The script pre-configures CORS on the Function App to allow the Static Web App plus localhost:3000, 5173, and 4200. To add another origin:
# Azure CLI - add a single origin
az functionapp cors add `
--name <function-app-name> `
--resource-group <rg> `
--allowed-origins https://my-other-domain.comTo replace the full list:
# Overwrite CORS origins via REST
$cors = @{
properties = @{
cors = @{
allowedOrigins = @('https://<swa>.azurestaticapps.net', 'https://my-other-domain.com')
supportCredentials = $true
}
}
} | ConvertTo-Json -Depth 5
Invoke-AzRestMethod `
-Path "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Web/sites/<function-app>/config/web?api-version=2023-12-01" `
-Method PATCH `
-Payload $corsNew-AzSqlServerFirewallRule `
-ResourceGroupName "<rg>" `
-ServerName "<sql-server-name>" `
-FirewallRuleName "MyHomeIP" `
-StartIpAddress "1.2.3.4" `
-EndIpAddress "1.2.3.4"- Function App logs: Function App → Monitor → Logs (queries Log Analytics via App Insights)
- Live metrics: Function App → Application Insights → Live metrics
- SQL performance: SQL Database → Intelligent Performance → Query Performance Insight
- All logs (KQL): Log Analytics Workspace → Logs — query across everything:
// Recent function executions with errors
requests
| where timestamp > ago(1h)
| where success == false
| project timestamp, name, resultCode, duration, operation_IdFree tier allowances:
- Static Web App — Free tier is genuinely free
- SQL Database — 100,000 vCore-seconds + 32 GB storage per month (one free-limit database per subscription)
- Function App — 1M executions/month free grant (shared with the Consumption plan)
- App Insights + Log Analytics — 5 GB/month ingestion free (workspace-based App Insights shares one allowance; ingestion is deducted from Log Analytics)
You will pay for:
- Storage Account — small ongoing cost (typically pennies/month for dev)
- Key Vault — $0.03 per 10k operations
- Data egress — if you exceed free thresholds
- SQL Database — if you exceed the free limits, behavior is controlled by
freeLimitExhaustionBehavior. The script sets this toAutoPause, which pauses the database instead of billing you. Change toBillOverUsageif you want continued availability.
Teardown: Delete the entire resource group to remove everything:
Remove-AzResourceGroup -Name "<rg-name>" -Force"Cannot open server 'xxx' requested by the login" Your client IP isn't in the SQL firewall. Add it:
New-AzSqlServerFirewallRule -ResourceGroupName "<rg>" -ServerName "<sql-server>" `
-FirewallRuleName "MyIP" -StartIpAddress "<your-ip>" -EndIpAddress "<your-ip>"Key Vault reference shows red X in Function App
- Managed identity doesn't have
Key Vault Secrets Userrole (should already be assigned by the script) - Secret name in the reference doesn't match the actual secret
- Secret was deleted or soft-deleted
- Check Function App → Environment variables → App settings and click the reference for the specific error
Function App deployment fails with storage errors
Verify the managed identity has Storage Blob Data Owner on the storage account (assigned by the script). Flex Consumption needs Owner, not just Contributor, because it manages blobs in the deployments container.
SQL database is paused / slow first query
Free-tier database is serverless with autoPauseDelay=60. First query after idle wakes it up (can take ~30 seconds). This is expected behavior.
"Authentication failed against tenant" warnings during login
You have access to an Entra tenant that requires MFA but the script wasn't given a -TenantId. Harmless — the script lists subscriptions from the tenants it could authenticate to. If you need that specific tenant, add -TenantId <id> to Connect-AzAccount.
Login failed for user '<token-identified principal>' during SQL user creation
This is an AAD admin propagation race — SQL Server's authentication backend hasn't finished replicating the admin configuration yet. The script retries 5 times with 30-second backoff, which usually clears it. If it still fails after that, wait ~2 minutes and manually run the three CREATE USER / ALTER ROLE statements shown in the script output from Cloud Shell or any SQL client where you're authenticated as the Entra admin:
CREATE USER [<function-app-name>] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [<function-app-name>];
ALTER ROLE db_datawriter ADD MEMBER [<function-app-name>];The script is not idempotent. To redeploy, delete the resource group first:
Remove-AzResourceGroup -Name "<rg-name>" -ForceThen re-run. The generated resource names include a random 6-digit suffix so you can also deploy multiple environments into separate resource groups.