Project: Web App with Database, Key Vault & Monitoring
By the end of this project you will have deployed a real web app on Azure entirely from code: a Python API on App Service that reads a menu from PostgreSQL, gets its database password from Key Vault through a managed identity, and sends logs and telemetry to Log Analytics and Application Insights. You will use almost every idea from this course, and you will finish with a portfolio piece you can show in interviews.
What you will build
The app is "Dabba Express", a tiffin service in Pune that publishes its daily menu as JSON. The design:
Internet (HTTPS)
|
+-------------------+ reads secret with managed identity
| App Service (B1) | ----------------------------+
| Python + Flask | |
+-------------------+ +---------------+
| | | Key Vault |
SQL over TLS | | telemetry | db-password |
v v +---------------+
+------------------+ +----------------------+ |
| PostgreSQL | | Application Insights | audit logs
| flexible server | +----------------------+ |
+------------------+ | v
+------> Log Analytics workspace <---- web app logsEverything lives in one resource group in Central India and is described in one Bicep file. Choices and why:
| Need | Choice | Lesson |
|---|---|---|
| Run the web code | App Service, Linux, Basic B1 | App Service |
| Store the menu | PostgreSQL flexible server, Burstable | Cosmos DB & PostgreSQL |
| Keep the password safe | Key Vault with RBAC, Key Vault reference | Key Vault |
| No secrets in code | System-assigned managed identity | Managed Identities |
| See what happens | Log Analytics, Application Insights, diagnostic settings | Monitoring |
| Repeatable build | Bicep with a parameter file | Infrastructure as Code |
Running this project costs money for as long as the App Service plan and the database exist. Check the pricing pages for App Service and PostgreSQL flexible server, set a budget alert, and delete the resource group when you finish.
Step 1: prepare the folder
Create this structure on your machine or in Cloud Shell:
dabba-express/
infra/
main.bicep
main.bicepparam
src/
app.py
requirements.txtKeeping infrastructure and application code in separate folders makes the CI/CD workflow simple later.
Step 2: write the application
The app reads its connection details from environment variables, which App Service fills from app settings. It never contains a password.
# src/app.py
import os
import psycopg
from flask import Flask, jsonify
if os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"):
from azure.monitor.opentelemetry import configure_azure_monitor
configure_azure_monitor()
app = Flask(__name__)
STARTER_MENU = [("Veg thali", 120), ("Poha", 40), ("Masala dosa", 90)]
_table_ready = False
def connect():
return psycopg.connect(
host=os.environ["DB_HOST"],
dbname=os.environ["DB_NAME"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
sslmode="require",
connect_timeout=10,
)
def ensure_table(conn):
global _table_ready
if _table_ready:
return
conn.execute(
"CREATE TABLE IF NOT EXISTS menu (item text PRIMARY KEY, price_rupees int NOT NULL)"
)
for item, price in STARTER_MENU:
conn.execute(
"INSERT INTO menu (item, price_rupees) VALUES (%s, %s) ON CONFLICT (item) DO NOTHING",
(item, price),
)
_table_ready = True
@app.get("/")
def menu():
with connect() as conn:
ensure_table(conn)
rows = conn.execute("SELECT item, price_rupees FROM menu ORDER BY item").fetchall()
return jsonify(kitchen="Dabba Express, Pune", menu=[{"item": i, "price": p} for i, p in rows])
@app.get("/health")
def health():
with connect() as conn:
conn.execute("SELECT 1")
return "ok"# src/requirements.txt
flask
psycopg[binary]
gunicorn
azure-monitor-opentelemetryA few details worth noticing:
sslmode="require"encrypts the connection to PostgreSQL.- Query values are passed as parameters (
%s), never pasted into SQL strings, which prevents SQL injection. - The table is created on first use to keep the project small. Real teams run database migrations as a separate pipeline step.
configure_azure_monitor()runs only when the connection string exists, so the app also runs on your laptop without Application Insights.- The rupee prices are menu data in the app, not Azure prices.
Step 3: write the Bicep file
This is the heart of the project. Read it section by section; every resource is one you have met before.
// infra/main.bicep
@description('Region for all resources')
param location string = resourceGroup().location
@description('Short app name used in resource names')
@minLength(3)
@maxLength(12)
param appName string = 'dabba'
@description('PostgreSQL administrator login')
param dbAdminUser string = 'dabbaadmin'
@secure()
@description('PostgreSQL administrator password, read from an environment variable at deploy time')
param dbAdminPassword string
var suffix = uniqueString(resourceGroup().id)
var dbName = 'menu'
var keyVaultSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6'
// ---------- Monitoring ----------
resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: 'log-${appName}-${suffix}'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: 'appi-${appName}-${suffix}'
location: location
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: law.id
}
}
// ---------- Key Vault and the database password ----------
resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: 'kv-${appName}-${take(suffix, 8)}'
location: location
properties: {
tenantId: subscription().tenantId
sku: {
family: 'A'
name: 'standard'
}
enableRbacAuthorization: true
softDeleteRetentionInDays: 7
}
}
resource dbSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
parent: kv
name: 'db-password'
properties: {
value: dbAdminPassword
}
}
resource kvDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
name: 'to-log-analytics'
scope: kv
properties: {
workspaceId: law.id
logs: [
{
categoryGroup: 'audit'
enabled: true
}
]
}
}
// ---------- PostgreSQL flexible server ----------
resource pg 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = {
name: 'pg-${appName}-${suffix}'
location: location
sku: {
name: 'Standard_B1ms'
tier: 'Burstable'
}
properties: {
version: '16'
administratorLogin: dbAdminUser
administratorLoginPassword: dbAdminPassword
storage: {
storageSizeGB: 32
}
backup: {
backupRetentionDays: 7
geoRedundantBackup: 'Disabled'
}
highAvailability: {
mode: 'Disabled'
}
}
}
resource pgDatabase 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = {
parent: pg
name: dbName
properties: {
charset: 'UTF8'
collation: 'en_US.utf8'
}
}
resource pgAllowAzure 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2024-08-01' = {
parent: pg
name: 'AllowAllAzureServicesAndResourcesWithinAzureIps'
properties: {
startIpAddress: '0.0.0.0'
endIpAddress: '0.0.0.0'
}
dependsOn: [
pgDatabase
]
}
// ---------- App Service ----------
resource plan 'Microsoft.Web/serverfarms@2024-04-01' = {
name: 'asp-${appName}-${suffix}'
location: location
kind: 'linux'
sku: {
name: 'B1'
}
properties: {
reserved: true
}
}
resource web 'Microsoft.Web/sites@2024-04-01' = {
name: 'app-${appName}-${suffix}'
location: location
kind: 'app,linux'
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: plan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'PYTHON|3.12'
minTlsVersion: '1.2'
ftpsState: 'Disabled'
healthCheckPath: '/health'
appSettings: [
{ name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' }
{ name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString }
{ name: 'DB_HOST', value: pg.properties.fullyQualifiedDomainName }
{ name: 'DB_NAME', value: dbName }
{ name: 'DB_USER', value: dbAdminUser }
{ name: 'DB_PASSWORD', value: '@Microsoft.KeyVault(SecretUri=${dbSecret.properties.secretUri})' }
]
}
}
}
resource webDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
name: 'to-log-analytics'
scope: web
properties: {
workspaceId: law.id
logs: [
{ category: 'AppServiceHTTPLogs', enabled: true }
{ category: 'AppServiceConsoleLogs', enabled: true }
]
}
}
// ---------- Let the web app read the secret ----------
resource kvSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(kv.id, web.id, keyVaultSecretsUserRoleId)
scope: kv
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', keyVaultSecretsUserRoleId)
principalId: web.identity.principalId
principalType: 'ServicePrincipal'
}
}
output webAppName string = web.name
output url string = 'https://${web.properties.defaultHostName}'
output keyVaultName string = kv.name
output postgresHost string = pg.properties.fullyQualifiedDomainNameHow the pieces connect:
- Names use
uniqueString(resourceGroup().id), so they are globally unique but stable across redeployments. The Key Vault name uses only 8 characters of it to stay within the 24-character limit. - The password arrives as a
@secure()parameter. Bicep writes it into two places only: the PostgreSQL server and the Key Vault secret. Secure parameters are not shown in deployment history. - The app setting
DB_PASSWORDholds a Key Vault reference, not the password. App Service resolves it using the app's system-assigned identity. - The role assignment gives that identity Key Vault Secrets User on this vault only. Its name uses
guid()so it is the same on every deployment, which keeps the deployment idempotent. - Symbolic references such as
law.idanddbSecret.properties.secretUritell Bicep the order to create things; you rarely needdependsOn. The one explicitdependsOnmakes the firewall rule wait for the database, because the server handles one change at a time. - The firewall rule named
AllowAllAzureServicesAndResourcesWithinAzureIpswith0.0.0.0allows connections from Azure services. It is convenient for learning but also allows other Azure customers' resources to try to connect. The production upgrade is private networking, listed at the end. - Diagnostic settings send Key Vault audit logs and App Service HTTP and console logs to the workspace.
PostgreSQL version 16 is used because it is valid for this API version; newer versions are available with newer API versions, as the database lesson explains.
Step 4: the parameter file
The password must never be written in a file. readEnvironmentVariable reads it from your shell at deployment time:
// infra/main.bicepparam
using 'main.bicep'
param appName = 'dabba'
param dbAdminUser = 'dabbaadmin'
param dbAdminPassword = readEnvironmentVariable('DB_ADMIN_PASSWORD')Step 5: deploy the infrastructure
Step 5a: sign in, check the subscription, and create the resource group. You need Owner (or Contributor plus a role that can create role assignments) on the group, because the template assigns a role.
az account show --query name --output tsv
az group create --name rg-dabba-cin --location centralindia --output noneStep 5b: generate a strong random password into an environment variable. It is never typed or printed.
export DB_ADMIN_PASSWORD="$(openssl rand -base64 24)"Step 5c: preview, then deploy.
cd dabba-express/infra
az deployment group what-if --resource-group rg-dabba-cin --parameters main.bicepparam
az deployment group create --resource-group rg-dabba-cin --name dabba-deploy \
--parameters main.bicepparam --query properties.outputs
unset DB_ADMIN_PASSWORDThe what-if output should list about a dozen resources to create. The deployment takes several minutes, mostly for PostgreSQL. When it finishes you should see the outputs:
{
"keyVaultName": {
"type": "String",
"value": "kv-dabba-k3x7q2m9"
},
"postgresHost": {
"type": "String",
"value": "pg-dabba-k3x7q2m9p4abc.postgres.database.azure.com"
},
"url": {
"type": "String",
"value": "https://app-dabba-k3x7q2m9p4abc.azurewebsites.net"
},
"webAppName": {
"type": "String",
"value": "app-dabba-k3x7q2m9p4abc"
}
}This output is traced from the documented shape of deployment outputs; your suffix will differ. When you pass a .bicepparam file, the CLI finds main.bicep through its using line, so --template-file is not needed.
The password now exists only inside PostgreSQL and Key Vault. If you deploy again later with a new random password, both are updated together; restart the web app afterwards so it picks up the new secret version.
Step 6: deploy the application code
Step 6a: zip the source folder and push it. SCM_DO_BUILD_DURING_DEPLOYMENT tells App Service to install requirements.txt during deployment.
cd ../src
zip -r ../app.zip app.py requirements.txt
webApp=$(az deployment group show --resource-group rg-dabba-cin --name dabba-deploy \
--query properties.outputs.webAppName.value --output tsv)
az webapp deploy --resource-group rg-dabba-cin --name "$webApp" --src-path ../app.zip --type zipdabba-deploy is the deployment name you chose in Step 5c, so its outputs can be read back at any time. You should see a message that the deployment succeeded, after the build log.
Step 6b: call the app.
curl "https://$webApp.azurewebsites.net/"{"kitchen":"Dabba Express, Pune","menu":[{"item":"Masala dosa","price":90},{"item":"Poha","price":40},{"item":"Veg thali","price":120}]}This output was checked by running the same Flask code locally against a stand-in for the database. Flask sorts JSON keys and prints compact JSON, and the SQL ORDER BY item sorts the menu alphabetically. Also check https://$webApp.azurewebsites.net/health, which should return ok.
Step 7: check the Key Vault reference
In the portal, open the web app, then Settings > Environment variables. The DB_PASSWORD row should show Key vault Reference with a green tick. If it shows an error:
- Wait a few minutes: new role assignments take time to apply.
- Restart the app with
az webapp restart --resource-group rg-dabba-cin --name "$webApp". - Check that the role assignment exists on the vault for the app's identity.
When the reference is unresolved, the app receives the literal @Microsoft.KeyVault(...) text as its password and the database rejects it, so /health fails. That is a useful clue.
Step 8: explore monitoring
Open a few pages of the app several times, then open the Log Analytics workspace and select Logs. Try these KQL queries:
AppRequests
| where TimeGenerated > ago(1h)
| summarize Requests = count(), AvgMs = round(avg(DurationMs), 1) by Name, ResultCodeAppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| project TimeGenerated, CsMethod, CsUriStem, ScStatus, TimeTaken
| order by TimeGenerated descAZKVAuditLogs
| where TimeGenerated > ago(1d)
| project TimeGenerated, OperationName, ResultTypeThe first shows requests recorded by Application Insights through OpenTelemetry. The second shows raw HTTP logs from App Service. The third shows Key Vault audit events, including App Service reading db-password. If a table is empty, wait 5 to 10 minutes: logs arrive with a delay. Depending on the workspace setting, Key Vault events can land in the AzureDiagnostics table instead of AZKVAuditLogs.
Add an alert for server errors from the monitoring lesson to finish the picture.
Step 9: clean up
Delete everything in one command when you are done:
az group delete --name rg-dabba-cin --yes --no-waitKey Vault soft delete keeps the deleted vault for 7 days (the retention set in the template). If you redeploy into a resource group with the same name within that time, the vault name will clash. Either wait, or purge it with az keyvault purge --name <vault-name>. In production you would turn on purge protection, which forbids purging.
Ideas to extend the project
- Add the GitHub Actions workflow with OIDC so every push to
mainruns what-if, deploys Bicep and publishes the code. - Split
main.bicepinto modules:monitoring.bicep,data.bicepandweb.bicep. - Add a metric alert and action group to the Bicep file so alerts are part of the code.
- Assign the Allowed locations policy to the resource group to keep everything in India.
- Add a
POST /menuendpoint protected by Microsoft Entra authentication on App Service.
Common mistakes
- Running what-if without the environment variable set. The deployment fails because
readEnvironmentVariablefinds no value. ExportDB_ADMIN_PASSWORDfirst. - Missing role assignment permission. If you are only Contributor, creating the role assignment fails with an authorization error. Ask for Owner or Role Based Access Control Administrator on the group.
- App returns 500 on first call. Check
az webapp log tailfor a database connection error, then the Key Vault reference status. - Forgetting to delete. The plan and database bill every hour they exist.
Key points
- One Bicep file can describe a complete app: compute, database, secrets, identity, monitoring and access.
@secure()parameters plusreadEnvironmentVariablekeep passwords out of files and history.- Key Vault references and a managed identity with Key Vault Secrets User keep the password out of app settings.
- Diagnostic settings and Application Insights give logs, requests and audit trails in one workspace.
- Learning shortcuts, such as the Azure-services firewall rule, must be replaced with private networking and Entra authentication for production.
Exercise
Extend main.bicep with an action group that emails owner@example.com and a metric alert called alert-http5xx on the web app that fires when the total Http5xx count is greater than 5 in a 5-minute window. Use Microsoft.Insights/actionGroups@2023-01-01 (location global) and Microsoft.Insights/metricAlerts@2018-03-01 (location global).
Show solution
resource actionGroup 'Microsoft.Insights/actionGroups@2023-01-01' = {
name: 'ag-${appName}'
location: 'global'
properties: {
groupShortName: 'dabbaops'
enabled: true
emailReceivers: [
{
name: 'owner'
emailAddress: 'owner@example.com'
useCommonAlertSchema: true
}
]
}
}
resource http5xxAlert 'Microsoft.Insights/metricAlerts@2018-03-01' = {
name: 'alert-http5xx'
location: 'global'
properties: {
severity: 2
enabled: true
scopes: [
web.id
]
evaluationFrequency: 'PT1M'
windowSize: 'PT5M'
criteria: {
'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
allOf: [
{
criterionType: 'StaticThresholdCriterion'
name: 'too-many-5xx'
metricName: 'Http5xx'
operator: 'GreaterThan'
threshold: 5
timeAggregation: 'Total'
}
]
}
actions: [
{
actionGroupId: actionGroup.id
}
]
}
}export DB_ADMIN_PASSWORD="$(az keyvault secret show --vault-name kv-dabba-k3x7q2m9 --name db-password --query value --output tsv)"
az deployment group create --resource-group rg-dabba-cin --parameters main.bicepparam --output none
unset DB_ADMIN_PASSWORDDurations use ISO 8601 format (PT5M is five minutes). Reading the existing password back from Key Vault, which needs the Key Vault Secrets User role for you, keeps the database and the secret unchanged when you redeploy; a new random password would also work but rotates both.
Quick Quiz
1. How does the web app get the database password?
The DB_PASSWORD setting holds a reference; the identity with Key Vault Secrets User reads the real value.
2. Why does the role assignment name use guid(kv.id, web.id, roleId)?
A deterministic GUID means redeployments update the same assignment instead of failing or duplicating.
3. What must replace the 0.0.0.0 firewall rule in production?
The Azure-services rule is a learning shortcut; private access keeps the database off the public internet.
Learn Microsoft Azure with a mentor
Free lessons get you started. Our Cloud & DevOps training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
