| title | Exercise 4: Deploy to Azure with Bicep |
|---|---|
| description | Step-by-step guide for deploying the evidence management application to Azure using Bicep and verifying Managed Identity storage access |
| ms.date | 2026-04-20 |
| ms.topic | tutorial |
| estimated_reading_time | 5 |
Deploy the complete evidence management solution to Azure using Bicep infrastructure-as-code templates. The default infra deploys a hardened-by-default architecture: a Virtual Network with regional VNet integration on both App Services, a Private Endpoint on the storage dfs sub-resource, ADLS Gen2 with shared keys disabled, and a Storage Blob Data Contributor RBAC assignment for the API Managed Identity. By the end of this exercise, both applications run in Azure App Service and evidence files download from ADLS Gen2 via the API Managed Identity over a Private Endpoint.
Duration: 20 minutes
Prerequisite: Exercise 3 completed and Azure CLI authenticated (az login).
Cost note: The deployed infrastructure uses an S1 App Service Plan (minimum SKU for VNet integration), a Private Endpoint, a Private DNS Zone, and a Standard LRS Storage Account, costing approximately $80/month. Delete the resource group after the workshop to avoid ongoing charges.
Resource Group
│
├── Virtual Network (vnet-evidence-<env>, 10.20.0.0/16)
│ ├── snet-app 10.20.1.0/24 (delegated to Microsoft.Web/serverFarms)
│ │ ├── SPA App Service (Node 20) ─┐
│ │ └── API App Service (Java 17) ─┤ Regional VNet integration
│ │ │ WEBSITE_VNET_ROUTE_ALL=1
│ └── snet-pe 10.20.2.0/24 (PE network policies disabled)
│ └── Private Endpoint (groupId=dfs) ──► ADLS Gen2
│
├── Private DNS Zone: privatelink.dfs.<storage-suffix>
│
├── ADLS Gen2 (HNS) — shared keys: disabled, publicNetworkAccess: Disabled
│
└── Application Insights + Log Analytics
If you would rather see the final state in Azure first and study the steps afterward, run the one-stop deployment script. It is idempotent and chains every step below (resource group, Bicep, builds, app deploys, evidence upload, smoke test) into a single command:
az login --tenant <tenantId> az account set --subscription <subscriptionIdOrName> .\scripts\deploy.ps1When it finishes, the smoke-test section will print the SPA URL (expecting
200) and the API/api/casesURL (expecting401, which proves JWT validation is on). Open the SPA URL, sign in with the same account you ran the script as (it has already been assignedCaseReader+CaseAdmin), and you should see all five sample cases. The full script reference is in the README's Fast-Track to Azure section.The manual steps below remain valuable as a learning reference — they show exactly what
deploy.ps1automates.
az group create \
--name rg-evidence-workshop \
--location canadacentralOpen infra/main.bicepparam and update the parameter values with the app registration IDs from Exercise 1:
| Parameter | Value |
|---|---|
spaClientId |
SPA Application (client) ID |
apiClientId |
API Application (client) ID |
tenantId |
Directory (tenant) ID |
Run the Bicep deployment. This creates the Virtual Network, App Service Plan (S1 minimum), two App Services with Regional VNet integration and Managed Identity, ADLS Gen2 storage (HNS, shared keys disabled, public access disabled), the Private Endpoint and Private DNS zone for storage, Application Insights, and the Storage Blob Data Contributor role assignment for the API Managed Identity.
For the seed step (Step 5) to work over OAuth without ever using a shared key, pass your public IP and Entra principal objectId. Bicep will temporarily allow-list your IP and grant your principal Storage Blob Data Contributor. After the data is uploaded, you'll re-deploy with deployerIp='' to flip storage back to publicNetworkAccess=Disabled.
MY_IP=$(curl -s https://api.ipify.org)
MY_OID=$(az ad signed-in-user show --query id -o tsv)
az deployment group create \
--resource-group rg-evidence-workshop \
--template-file infra/main.bicep \
--parameters infra/main.bicepparam \
--parameters deployerIp=$MY_IP deployerPrincipalId=$MY_OID deployerPrincipalType=UserThe deployment takes 10-15 minutes (the Private Endpoint and Private DNS Zone add a few minutes over a vanilla App Service deploy). While it runs, proceed to Step 4.
While the infrastructure deploys, build both applications in separate terminals:
Build the SPA:
cd sample-app/spa
ng build --configuration productionBuild the API:
cd sample-app/api
mvn clean package -DskipTestsAfter the deployment completes, upload the sample evidence PDFs to the storage container. The --auth-mode login flag uses your Entra ID credentials — shared keys are disabled at the storage account, so OAuth is the only way in. Bicep already granted your principal Storage Blob Data Contributor and added your IP to the allow-list in Step 3, so this should just work after a short RBAC propagation delay:
STORAGE_ACCOUNT=$(az deployment group show \
--resource-group rg-evidence-workshop \
--name main \
--query properties.outputs.storageAccountNameOutput.value -o tsv)
# Wait ~30s for the role assignment to propagate, then upload
sleep 30
az storage container create --account-name $STORAGE_ACCOUNT --name evidence --auth-mode login
az storage blob upload-batch \
--account-name $STORAGE_ACCOUNT \
--destination evidence \
--source sample-app/api/src/main/resources/data/sample-evidence \
--auth-mode login --overwriteNow that the data is in place, re-deploy Bicep with deployerIp='' so the storage account flips back to publicNetworkAccess=Disabled. From here on, only the App Services (via the Private Endpoint) can reach storage data:
az deployment group create \
--resource-group rg-evidence-workshop \
--template-file infra/main.bicep \
--parameters infra/main.bicepparam \
--parameters deployerIp='' deployerPrincipalId=''SPA_APP=$(az deployment group show \
--resource-group rg-evidence-workshop \
--name main \
--query properties.outputs.spaAppName.value -o tsv)
cd sample-app/spa/dist/evidence-portal/browser
zip -r ../../../../spa.zip .
az webapp deploy \
--resource-group rg-evidence-workshop \
--name $SPA_APP \
--src-path ../../../../spa.zip \
--type zipSome Angular versions emit straight to
dist/evidence-portal/without abrowser/sub-folder. Adjust thecdaccordingly if you don't see the inner directory.
API_APP=$(az deployment group show \
--resource-group rg-evidence-workshop \
--name main \
--query properties.outputs.apiAppName.value -o tsv)
az webapp deploy \
--resource-group rg-evidence-workshop \
--name $API_APP \
--src-path sample-app/api/target/evidence-api-0.0.1-SNAPSHOT.jar \
--type jarThe Bicep deployment already populates the necessary app settings on both App Services (SPRING_PROFILES_ACTIVE=prod, JWT_ISSUER_URI, JWT_AUDIENCE, AZURE_TENANT_ID, CORS_ALLOWED_ORIGINS, STORAGE_ACCOUNT_NAME, and APPLICATIONINSIGHTS_CONNECTION_STRING). If you ever need to inspect or override them, use:
az webapp config appsettings list \
--resource-group rg-evidence-workshop \
--name $API_APP \
--output tableTo force a restart after changing settings:
az webapp restart --resource-group rg-evidence-workshop --name $API_APP-
Get the SPA URL:
echo "https://$SPA_APP.azurewebsites.net"
-
Open the SPA app registration in the Entra Admin Center.
-
Go to Authentication and add a new SPA platform redirect URI:
https://<spa-app-name>.azurewebsites.net. -
Select Save.
The fast-track
deploy.ps1script does this for you by re-invokingsetup-entra-apps.ps1 -ProductionRedirectUri <spaUrl>after the App Service URLs are known.
- Navigate to
https://<spa-app-name>.azurewebsites.netin your browser. - Sign in with your Entra ID account.
- Browse to
/casesand verify the case list loads. - Open a case and download an evidence file.
- Confirm the PDF opens correctly. The file now comes from ADLS Gen2 via the API's Managed Identity over the Private Endpoint, with no storage account keys involved (shared keys are disabled at the storage account).
Confirm each of these items works in the deployed environment:
- Both App Services are running (check the Azure portal or
az webapp show) - Sign-in redirects to Entra ID and returns to the deployed SPA URL
- The header shows your signed-in name and the API
/api/meendpoint returns your roles + scopes - Case list loads with data (proves the API can read ADLS Gen2 over the Private Endpoint via Managed Identity)
- Evidence file downloads as a valid PDF
- Storage account shows
publicNetworkAccess: DisabledandallowSharedKeyAccess: falseinaz storage account show - Application Insights shows telemetry from both applications
Delete the resource group to remove all deployed resources and stop billing:
az group delete --name rg-evidence-workshop --yes --no-waitAlso remove the production redirect URI from the SPA app registration if you no longer need it.
| Problem | Cause | Fix |
|---|---|---|
| Deployment fails with quota error | Subscription has reached resource limits for the region | Try a different region or request a quota increase |
| SPA returns 404 after deployment | SPA build output not in the correct directory | Verify ng build output is in dist/evidence-portal/browser and the zip was created from that directory |
| API returns 500 on startup | Missing environment variables | Run configure-app-settings.sh and restart the API App Service: az webapp restart --name $API_APP --resource-group rg-evidence-workshop |
| Evidence download returns 403 from Storage | Managed Identity role assignment has not propagated | Wait 5 minutes for the Storage Blob Data Reader role to propagate, then retry |
| "redirect_uri mismatch" on sign-in | Production redirect URI not added to app registration | Add https://<spa-app-name>.azurewebsites.net as a SPA platform redirect URI in the Entra Admin Center |
| CORS errors in the deployed SPA | API CORS configuration only allows localhost | The API production profile should allow the deployed SPA origin; verify application.properties CORS settings |