Skip to content

10 · Capstone Project — End-to-End Web App

Time to combine everything from this level into one working system: a static frontend, a serverless API backend, and a managed database — deployed with the az CLI, using only free-tier services, and fully torn down at the end so nothing keeps billing after you're done.

Architecture

Browser
Azure Static Web App  (HTML/JS frontend, free tier)
  │  fetch("/api/notes")
Azure Function (Consumption plan)  ── linked backend of the Static Web App
Azure Cosmos DB (free tier, Core/SQL API)  — stores notes as JSON documents

You'll build a tiny "notes" app: a page with a form that posts a note, and a list that fetches and displays all notes, backed by a Function App reading and writing Cosmos DB. Static Web Apps can link directly to a Function App as its "managed backend," which handles CORS and routing (/api/*) for you automatically — no separate hosting or reverse proxy needed.

Step 1 — Resource group and database

az group create --name rg-capstone --location eastus

az cosmosdb create \
  --resource-group rg-capstone \
  --name cosmos-capstone$RANDOM \
  --locations regionName=eastus failoverPriority=0 \
  --default-consistency-level Session \
  --enable-free-tier true

COSMOS_ACCOUNT=$(az cosmosdb list --resource-group rg-capstone --query "[0].name" --output tsv)

az cosmosdb sql database create \
  --account-name $COSMOS_ACCOUNT \
  --resource-group rg-capstone \
  --name notesdb

az cosmosdb sql container create \
  --account-name $COSMOS_ACCOUNT \
  --resource-group rg-capstone \
  --database-name notesdb \
  --name notes \
  --partition-key-path "/id" \
  --throughput 400

COSMOS_KEY=$(az cosmosdb keys list \
  --name $COSMOS_ACCOUNT --resource-group rg-capstone \
  --query primaryMasterKey --output tsv)
COSMOS_ENDPOINT=$(az cosmosdb show \
  --name $COSMOS_ACCOUNT --resource-group rg-capstone \
  --query documentEndpoint --output tsv)

Step 2 — The Function App backend

az storage account create \
  --name stcapstonefn$RANDOM \
  --resource-group rg-capstone \
  --sku Standard_LRS
STORAGE_ACCOUNT=$(az storage account list --resource-group rg-capstone --query "[0].name" --output tsv)

az functionapp create \
  --resource-group rg-capstone \
  --name func-capstone$RANDOM \
  --storage-account $STORAGE_ACCOUNT \
  --consumption-plan-location eastus \
  --runtime python \
  --runtime-version 3.11 \
  --functions-version 4 \
  --os-type Linux

FUNCTION_APP=$(az functionapp list --resource-group rg-capstone --query "[0].name" --output tsv)

# Store the Cosmos DB connection as app settings (not hardcoded in source)
az functionapp config appsettings set \
  --resource-group rg-capstone \
  --name $FUNCTION_APP \
  --settings COSMOS_ENDPOINT="$COSMOS_ENDPOINT" COSMOS_KEY="$COSMOS_KEY"

Scaffold and write the function locally:

func init capstone-func --python
cd capstone-func
func new --name notes --template "HTTP trigger" --authlevel "anonymous"

notes/__init__.py:

import json
import os
import uuid
import azure.functions as func
from azure.cosmos import CosmosClient

client = CosmosClient(
    url=os.environ["COSMOS_ENDPOINT"],
    credential=os.environ["COSMOS_KEY"],
)
container = client.get_database_client("notesdb").get_container_client("notes")


def main(req: func.HttpRequest) -> func.HttpResponse:
    if req.method == "POST":
        body = req.get_json()
        item = {"id": str(uuid.uuid4()), "text": body.get("text", "")}
        container.create_item(item)
        return func.HttpResponse(
            json.dumps(item), status_code=201, mimetype="application/json"
        )

    # GET: return all notes
    items = list(container.read_all_items())
    return func.HttpResponse(json.dumps(items), mimetype="application/json")

requirements.txt:

azure-functions
azure-cosmos

Test locally (using the real Cosmos DB endpoint via a local local.settings.json with the same COSMOS_ENDPOINT/COSMOS_KEY values), then deploy:

func azure functionapp publish $FUNCTION_APP

Step 3 — The Static Web App frontend

www/index.html:

<!DOCTYPE html>
<html>
<head><title>Capstone Notes</title></head>
<body>
  <h1>Notes</h1>
  <form id="note-form">
    <input id="note-text" placeholder="Write a note..." required>
    <button type="submit">Add</button>
  </form>
  <ul id="note-list"></ul>

  <script>
    const list = document.getElementById("note-list");
    const form = document.getElementById("note-form");

    async function loadNotes() {
      const res = await fetch("/api/notes");
      const notes = await res.json();
      list.innerHTML = notes.map(n => `<li>${n.text}</li>`).join("");
    }

    form.addEventListener("submit", async (e) => {
      e.preventDefault();
      const text = document.getElementById("note-text").value;
      await fetch("/api/notes", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });
      document.getElementById("note-text").value = "";
      loadNotes();
    });

    loadNotes();
  </script>
</body>
</html>

Create the Static Web App and push this folder to a GitHub repo (Static Web Apps deploy via a GitHub Actions workflow that az sets up for you):

az staticwebapp create \
  --resource-group rg-capstone \
  --name swa-capstone \
  --source https://github.com/<your-username>/capstone-notes \
  --location eastus2 \
  --branch main \
  --app-location "www" \
  --login-with-github

This command creates the GitHub Actions workflow file in your repo automatically and triggers the first deploy on push.

az staticwebapp backends link \
  --name swa-capstone \
  --resource-group rg-capstone \
  --backend-resource-id $(az functionapp show \
      --name $FUNCTION_APP --resource-group rg-capstone --query id --output tsv) \
  --backend-region eastus

Linking a backend makes every request to /api/* from the Static Web App's domain automatically route to your Function App, with CORS handled for you — the frontend's fetch("/api/notes") just works with no extra configuration.

Step 4 — Verify it end-to-end

az staticwebapp show \
  --name swa-capstone --resource-group rg-capstone \
  --query defaultHostname --output tsv

Open https://<hostname>/ in a browser, add a note through the form, refresh, and confirm it persists (it's coming from Cosmos DB, not browser state).

Step 5 — Teardown (do this — it stops all billing)

Don't skip this section

Cosmos DB's free tier is one account per subscription, Static Web Apps' free tier is generous but not unlimited, and a Function App on Consumption scales to zero but its Storage Account still holds data. None of this is expensive left running for a day, but the whole point of a capstone is to practice the full lifecycle — creation and clean shutdown — like you would with any real project.

The single command that undoes everything in this capstone:

az group delete --name rg-capstone --yes --no-wait

Confirm it's actually gone (don't just trust --no-wait and walk away):

# Poll until this returns an empty list / error "not found"
az group show --name rg-capstone --output table

If you also connected a custom domain or created a GitHub Actions secret tied to this Static Web App, remove the now-broken GitHub Actions workflow file/secret from your repo too, since the resource group deletion doesn't touch anything living in GitHub:

# From your local clone of the capstone-notes repo
rm .github/workflows/azure-static-web-apps-*.yml
git add -A && git commit -m "Remove Azure Static Web Apps deploy workflow (resources torn down)"
git push

Finally, double-check nothing else is left in the subscription from earlier modules in this level, since it's easy to forget a resource group from Module 3 or 6:

az group list --output table

Delete anything you don't recognize as intentionally kept, the same way.

How It Actually Works

This capstone wires together every provisioning path covered so far — CLI imperative calls, Bicep declarative templates, and the Portal — against the same underlying target: the ARM control plane. Regardless of which tool issues a request, ARM performs the identical sequence for every resource: authenticate the caller's token against Entra ID, evaluate Azure RBAC role assignments at every scope from the resource up through resource group, subscription, and management group to authorize the specific operation (e.g. Microsoft.Storage/storageAccounts/write), run any applicable Azure Policy definitions against the request body before allowing it through (a deny effect policy rejects the PUT at this stage, before the resource provider ever sees it), and only then forward the validated request to the owning resource provider. This is why a resource you're permitted to create by RBAC can still be blocked — Policy and RBAC are two independent gates evaluated in sequence, not one merged check.

The resource group itself is the unit ARM uses to track a deployment's history: every az deployment group create call is recorded as a correlated deployment object inside the group, with its own template snapshot, parameters, and operation-level status for every resource it touched — which is exactly what lets you inspect "what got deployed and in what order" after the fact via az deployment group show, and what az group delete walks when tearing the whole project down: it doesn't need the original template at all, since deletion is driven purely by "what resources currently exist in this group," each deleted through its own resource provider's delete API in parallel where dependencies allow.

Cheat sheet

Command Purpose
az cosmosdb create --enable-free-tier true Free-tier Cosmos DB account.
az functionapp create --consumption-plan-location --runtime Serverless Function App backend.
az functionapp config appsettings set --settings Store connection info as app config, not hardcoded.
az staticwebapp create --source --login-with-github Create a Static Web App wired to a GitHub repo + Actions deploy.
az staticwebapp backends link --backend-resource-id Attach a Function App as the SWA's /api/* backend.
az group delete --name --yes --no-wait Tear down every resource in the project at once.
az group list -o table Sanity-check nothing else is still running.

Exercise

  1. Build the notes app exactly as described, end to end, and confirm a note you add survives a page refresh (proving it round-trips through Cosmos DB, not just local JS state).
  2. Add a DELETE /api/notes/{id} route to the Function App and a delete button per note in the frontend — this exercises writing a second HTTP route in the same function app and updating the container with a partition-key-aware delete call.
  3. Run the full teardown sequence above, and verify with az group show --name rg-capstone that the group is gone.
  4. As a final check, run az group list --output table and confirm your subscription is back to a clean state before moving on to Level 2.