EthicsComputerCourses
Join training +91 90532 10052

Project: Web App with Database, Key Vault & Monitoring

Microsoft Azure · Lesson 29 of 29 Advanced 12 min read Updated

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:

Text
              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 logs

Everything lives in one resource group in Central India and is described in one Bicep file. Choices and why:

NeedChoiceLesson
Run the web codeApp Service, Linux, Basic B1App Service
Store the menuPostgreSQL flexible server, BurstableCosmos DB & PostgreSQL
Keep the password safeKey Vault with RBAC, Key Vault referenceKey Vault
No secrets in codeSystem-assigned managed identityManaged Identities
See what happensLog Analytics, Application Insights, diagnostic settingsMonitoring
Repeatable buildBicep with a parameter fileInfrastructure 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:

Text
dabba-express/
  infra/
    main.bicep
    main.bicepparam
  src/
    app.py
    requirements.txt

Keeping 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.

Python
# 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"
Text
# src/requirements.txt
flask
psycopg[binary]
gunicorn
azure-monitor-opentelemetry

A 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.

Bicep
// 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.fullyQualifiedDomainName

How 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_PASSWORD holds 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.id and dbSecret.properties.secretUri tell Bicep the order to create things; you rarely need dependsOn. The one explicit dependsOn makes the firewall rule wait for the database, because the server handles one change at a time.
  • The firewall rule named AllowAllAzureServicesAndResourcesWithinAzureIps with 0.0.0.0 allows 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:

Text
// 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.

Terminal
az account show --query name --output tsv
az group create --name rg-dabba-cin --location centralindia --output none

Step 5b: generate a strong random password into an environment variable. It is never typed or printed.

Terminal
export DB_ADMIN_PASSWORD="$(openssl rand -base64 24)"

Step 5c: preview, then deploy.

Terminal
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_PASSWORD

The 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:

Output
{
  "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.

Terminal
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 zip

dabba-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.

Terminal
curl "https://$webApp.azurewebsites.net/"
Output
{"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:

  1. Wait a few minutes: new role assignments take time to apply.
  2. Restart the app with az webapp restart --resource-group rg-dabba-cin --name "$webApp".
  3. 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:

Text
AppRequests
| where TimeGenerated > ago(1h)
| summarize Requests = count(), AvgMs = round(avg(DurationMs), 1) by Name, ResultCode
Text
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| project TimeGenerated, CsMethod, CsUriStem, ScStatus, TimeTaken
| order by TimeGenerated desc
Text
AZKVAuditLogs
| where TimeGenerated > ago(1d)
| project TimeGenerated, OperationName, ResultType

The 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:

Terminal
az group delete --name rg-dabba-cin --yes --no-wait

Key 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

  1. Add the GitHub Actions workflow with OIDC so every push to main runs what-if, deploys Bicep and publishes the code.
  2. Split main.bicep into modules: monitoring.bicep, data.bicep and web.bicep.
  3. Add a metric alert and action group to the Bicep file so alerts are part of the code.
  4. Assign the Allowed locations policy to the resource group to keep everything in India.
  5. Add a POST /menu endpoint protected by Microsoft Entra authentication on App Service.

Common mistakes

  • Running what-if without the environment variable set. The deployment fails because readEnvironmentVariable finds no value. Export DB_ADMIN_PASSWORD first.
  • 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 tail for 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 plus readEnvironmentVariable keep 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
Bicep
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
      }
    ]
  }
}
Terminal
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_PASSWORD

Durations 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?

2. Why does the role assignment name use guid(kv.id, web.id, roleId)?

3. What must replace the 0.0.0.0 firewall rule in production?

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