Skip to content
CAMPUX Cloud Bootcamp Lab · Deploy · Secure · Ship ← All labs
Hands-On Lab · Advanced
~90–120 min · A few dollars · Cloud Shell
Azure CLI · torn down at the end
Deploy · Private Web Platform

Deploy Campux Retail: the storefront, behind a private network.

Take the real storefront container and stand it up the way an enterprise runs it — a public Application Gateway with a WAF as the only door in, the web app hidden inside a VNet, and the database reachable over nothing but a private endpoint. This is the guided build of the core of Capstone Plate I, one command at a time, torn down when you finish.

The application Campux Retail is a real, deployable storefront — one web container that already runs on App Service and on AKS. You deploy that container in this lab; you do not have to write it.
github.com/kloudcaptain/campux-retail ↗
Why

Public front, private back — and why the shape matters

The lazy way to ship a web app is to give the App Service a public hostname, point the database at it with a firewall rule, and call it done. It works, and it is exactly the shape that gets an estate breached: the app is on the internet, the database is one leaked connection string from the internet, and there is nothing between an attacker and your login form. The enterprise shape inverts all of that. One public entry point — an Application Gateway with a Web Application Firewall — takes every request, inspects it, and forwards only what survives. The web app itself has no public access; it lives inside a virtual network. The database has no public access either; it is reached over a private endpoint that only exists inside your VNet. Traffic flows one way, through inspection, and every tier below the gateway is dark to the internet.

This lab builds that shape around the Campux Retail container. You will lay down a VNet with three purpose-built subnets, deploy the container to App Service, join the app to the network, lock Azure SQL behind a private endpoint with its own private DNS, cut off the app's public access, and put a WAF-fronted Application Gateway in front as the single door. At the end you hit the gateway, see the store, and confirm the tiers behind it are unreachable any other way.

One door in, inspected. Everything behind it, dark to the internet.

Before you begin — one-time setup

You need a free Azure account and the Azure CLI (az), signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs, and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash), preinstalled and already signed in.

This costs a little, so tear it down

Unlike the pure-networking labs, this one runs billable resources: an App Service plan, an Application Gateway v2, and an Azure SQL database. Left running they are a few dollars a day, not free. The final step deletes the whole resource group in one command — do it the moment you are done, and the total cost of this lab is a rounding error.

The guided build of Capstone Plate I

This lab is the core of Capstone Plate I — The Enterprise Web Platform: the private data tier, the WAF, the public front door, plus the Key Vault references, keyless identity to SQL, TLS from a vault certificate, and the OIDC pipeline you build here. The capstone then adds what this lab still leaves out — deployment slots, telemetry and alerting, zone redundancy, DDoS protection, and Azure Policy governance. Build this first; the capstone is where you wrap it in the operations layer and make the repository your own.

Setup

Resource group and variables

Everything goes in one resource group so teardown is a single delete. Set the names as shell variables once — every command below reuses them, and Azure SQL server names and gateway public-IP DNS labels must be globally unique, so a random suffix keeps you from colliding with someone else running this lab.

# Windows/Git Bash: stop it mangling /subscriptions/... arguments (harmless on macOS/Linux)
export MSYS_NO_PATHCONV=1

RG="campux-retail-rg"
LOC="uksouth"                          # or eastus — pick one near you
SUFFIX=$RANDOM                         # keeps globally-unique names from colliding

VNET="campux-vnet"
PLAN="campux-plan"
APP="campux-web-$SUFFIX"               # App Service name is part of a public hostname
SQLSRV="campux-sql-$SUFFIX"           # SQL logical server name is global
SQLDB="campuxdb"
IMAGE="mcr.microsoft.com/azuredocs/aci-helloworld:latest"   # stand-in web container; swap for your Campux Retail image (see note)

az group create -n "$RG" -l "$LOC"
The container image

The IMAGE above is a tiny public web container that stands in so the lab runs for anyone with no registry setup. To deploy the real thing, build and push github.com/kloudcaptain/campux-retail to an Azure Container Registry and set IMAGE to <your-acr>.azurecr.io/campux-retail:latest; the App Service create command is identical, and you grant the web app's identity AcrPull on the registry. The network shape you build here does not change one line for the real image.

Checkpoint The resource group exists and your variables are set. Run echo $APP $SQLSRV and confirm both carry the random suffix — if they are bare, the later create commands will likely fail on a name-already-taken error.
Step 1

A VNet with three purpose-built subnets

Each tier of this platform needs its own subnet, because Azure attaches different rules to each. The gateway needs a subnet of its own (Application Gateway will not share one). The app-integration subnet must be delegated to Microsoft.Web/serverFarms or regional VNet integration silently does nothing. The private-endpoint subnet just holds the private NICs for SQL. Give the VNet 10.20.0.0/16 and carve three non-overlapping /24s.

# VNet + the gateway subnet in one call
az network vnet create -g "$RG" -n "$VNET" --address-prefix 10.20.0.0/16 \
  --subnet-name snet-appgw --subnet-prefix 10.20.1.0/24

# app-integration subnet, DELEGATED to App Service — the step everyone forgets
az network vnet subnet create -g "$RG" --vnet-name "$VNET" -n snet-app \
  --address-prefixes 10.20.2.0/24 \
  --delegations Microsoft.Web/serverFarms

# private-endpoint subnet — holds the private NIC for SQL
az network vnet subnet create -g "$RG" --vnet-name "$VNET" -n snet-pe \
  --address-prefixes 10.20.3.0/24

The plan, written down so it is not a mystery later:

snet-appgw   10.20.1.0/24   Application Gateway v2 (public IP lives here)
snet-app     10.20.2.0/24   App Service regional VNet integration (delegated)
snet-pe      10.20.3.0/24   Private endpoints (Azure SQL private NIC)
Checkpoint Three subnets exist. Confirm the delegation actually took — this is the one that fails silently:
az network vnet subnet show -g "$RG" --vnet-name "$VNET" -n snet-app \
  --query "delegations[].serviceName" -o tsv      # -> Microsoft.Web/serverFarms
If that returns nothing, VNet integration in Step 3 will report success and route no traffic. Fix the delegation now.
Step 2

Deploy the storefront container to App Service

Create a Linux App Service plan, then a web app that runs the container image. App Service pulls the image, starts it, and gives you a hostname straight away — useful for one sanity check before you take the public access away.

# Linux plan — P1v3 is the smallest tier that supports VNet integration cleanly
az appservice plan create -g "$RG" -n "$PLAN" --is-linux --sku P1v3

# web app from the container image
az webapp create -g "$RG" -p "$PLAN" -n "$APP" \
  --deployment-container-image-name "$IMAGE"

# tell App Service which port the container listens on (the stand-in serves on 80)
az webapp config appsettings set -g "$RG" -n "$APP" \
  --settings WEBSITES_PORT=80

Give the container a moment to pull and start, then hit its default hostname to confirm it serves before you lock it down.

az webapp show -g "$RG" -n "$APP" --query defaultHostName -o tsv
# open https://<that-hostname> in a browser — you should see the app respond
Checkpoint The default hostname returns the app. That public URL is temporary — you remove it in Step 6 so the gateway becomes the only way in. A real Campux Retail image also exposes a /health path that reports its dependencies; when you swap the image, set the health check with az webapp config set --health-check-path /health so App Service restarts an instance that stops answering it.
Step 3

Join the web app to the VNet

Regional VNet integration gives the web app a foothold inside snet-app, so its outbound traffic — the calls it makes to the database — leaves through your network and can reach a private endpoint. Without this, the app has no route to a private-only SQL server at all.

az webapp vnet-integration add -g "$RG" -n "$APP" \
  --vnet "$VNET" --subnet snet-app
Checkpoint Integration is listed for the app:
az webapp vnet-integration list -g "$RG" -n "$APP" -o table
The gotcha worth repeating: integration needs a subnet delegated to Microsoft.Web/serverFarms. On an un-delegated subnet the command can still appear to succeed while the app's outbound traffic never actually enters the VNet — which is why you verified the delegation in Step 1 before getting here.
Step 4

Azure SQL, private-only, reached over a private endpoint

Create the logical server and a database, then take the server off the public internet entirely and give it a private door instead. The private endpoint places a NIC in snet-pe with a private IP; a private DNS zone makes the server's normal hostname resolve to that private IP from inside the VNet, so the app connects by the same name it always would — it just resolves privately.

# logical server + database (SQL auth here to keep the lab short; the capstone goes keyless)
SQLPASS="P$(openssl rand -hex 8)!aZ"          # random, meets complexity rules
az sql server create -g "$RG" -n "$SQLSRV" -l "$LOC" \
  --admin-user campuxadmin --admin-password "$SQLPASS"

az sql db create -g "$RG" -s "$SQLSRV" -n "$SQLDB" \
  --service-objective S0

# pull the server off the public internet — no firewall rule can reach it now
az sql server update -g "$RG" -n "$SQLSRV" --set publicNetworkAccess=Disabled

# private DNS zone for SQL, linked to the VNet so private names resolve inside it
az network private-dns zone create -g "$RG" -n privatelink.database.windows.net
az network private-dns link vnet create -g "$RG" \
  --zone-name privatelink.database.windows.net \
  --name pdns-link --virtual-network "$VNET" --registration-enabled false

# the private endpoint — a NIC for the SQL server, inside snet-pe
SQLID=$(az sql server show -g "$RG" -n "$SQLSRV" --query id -o tsv)
az network private-endpoint create -g "$RG" -n sql-pe \
  --vnet-name "$VNET" --subnet snet-pe \
  --private-connection-resource-id "$SQLID" \
  --group-id sqlServer --connection-name sql-pe-conn

# wire the endpoint's private IP into the DNS zone automatically
az network private-endpoint dns-zone-group create -g "$RG" \
  --endpoint-name sql-pe -n sql-zone-group \
  --private-dns-zone privatelink.database.windows.net --zone-name sql
Checkpoint Public access is off and the name resolves privately. The proof an interviewer wants is a DNS lookup from inside the VNet: the server's <name>.database.windows.net should resolve to a 10.20.3.x address, not a public one. From a resource on the VNet (for example the App Service using nslookup in the Kudu console, or a small test VM on snet-pe), run nslookup $SQLSRV.database.windows.net and confirm it returns the private-endpoint IP. From your laptop the same name resolves to nothing usable — which is the point.
Step 5

Key Vault, managed identity, and keyless SQL

Step 4 left a SQL password in an app setting. That is the thing that leaks. Here you retire it. Give the web app its own system-assigned managed identity — an identity Azure creates and rotates, with no secret you ever see — then let that identity read secrets from Key Vault and authenticate to SQL directly. Two secrets disappear in this step: the connection password, and the idea that you have to store one at all.

# turn on the app's system-assigned identity; capture its principalId (an object id in Entra)
az webapp identity assign -g "$RG" -n "$APP"
APPID=$(az webapp identity show -g "$RG" -n "$APP" --query principalId -o tsv)

Create the vault in RBAC authorization mode. The older access-policy model and RBAC are mutually exclusive per vault, and RBAC is the current default because it puts vault permissions in the same place as every other Azure role. Then grant the app's identity exactly one role — Key Vault Secrets User, which is read-only on secret values — scoped to this vault and nothing wider.

# vault name is globally unique; RBAC mode, not access policies
KV="campux-kv-$SUFFIX"
az keyvault create -g "$RG" -n "$KV" -l "$LOC" \
  --enable-rbac-authorization true

KVID=$(az keyvault show -g "$RG" -n "$KV" --query id -o tsv)

# least privilege: read secret VALUES only, only on this vault
az role assignment create \
  --assignee-object-id "$APPID" --assignee-principal-type ServicePrincipal \
  --role "Key Vault Secrets User" --scope "$KVID"

Store an example secret and wire it into the app as a Key Vault reference — App Service resolves the reference at startup and hands the app the value, so the app setting holds a pointer, not the secret. The ordering gotcha matters: the app's identity must already hold Key Vault Secrets User before the reference is set, or App Service cannot resolve it and the setting shows the literal @Microsoft.KeyVault(...) string instead of the value.

# a sample secret the app will read by reference
az keyvault secret set --vault-name "$KV" -n AppConfig--Message \
  --value "served from Key Vault, not from an app setting"

# reference it by SecretUri — App Service resolves this at runtime using the app identity
az webapp config appsettings set -g "$RG" -n "$APP" --settings \
  "[email protected](SecretUri=https://$KV.vault.azure.net/secrets/AppConfig--Message/)"

Now make SQL keyless. Set the app's managed identity as an Entra (Azure AD) admin on the logical server, then change the connection string to token auth — no username, no password, just Authentication=Active Directory Managed Identity. The app presents its identity's token and SQL trusts it. The old $SQLPASS from Step 4 is retired; delete any connection setting that carried it.

# make the app's identity an Entra admin on the SQL server
az sql server ad-admin create -g "$RG" -s "$SQLSRV" \
  --display-name "campux-web-identity" --object-id "$APPID"

# keyless connection string — no user, no password, token auth via the managed identity
az webapp config connection-string set -g "$RG" -n "$APP" \
  --connection-string-type SQLAzure --settings \
  "CampuxDb=Server=tcp:$SQLSRV.database.windows.net,1433;Database=$SQLDB;Authentication=Active Directory Managed Identity;Encrypt=True;"

# the SQL password is now dead weight — remove it if Step 4 wrote it anywhere
unset SQLPASS
The one-time in-database grant a real app runs

Making the identity an Entra server admin lets the lab connect without more setup. A real Campux Retail grants at the database level instead: connected to $SQLDB as an Entra admin, it runs CREATE USER [campux-web-$SUFFIX] FROM EXTERNAL PROVIDER; then ALTER ROLE db_datareader ADD MEMBER [campux-web-$SUFFIX]; (and db_datawriter). That is a T-SQL step, not an az command — it runs once, inside the database, against the app's identity name.

Last, put a TLS certificate in Key Vault for the gateway to read in the next step. A self-signed cert is fine here — the mechanism is what you are practising, not the certificate authority. Say it plainly: this cert is self-signed, so browsers will warn on it; a real front door uses one issued by a CA.

# self-signed cert via the default policy — a real one comes from a CA
az keyvault certificate create --vault-name "$KV" -n appgw-cert \
  --policy "$(az keyvault certificate get-default-policy)"
Checkpoint The identity exists and the app setting is a reference, not a value. az webapp identity show -g "$RG" -n "$APP" --query principalId -o tsv returns a GUID. az webapp config appsettings list -g "$RG" -n "$APP" --query "[?name=='AppConfig__Message'].value" -o tsv shows the @Microsoft.KeyVault(...) reference string — App Service resolves it to the real value only for the running app, which is exactly why the setting itself never holds the secret. If it shows the literal @Microsoft.KeyVault text to the app too, the role assignment had not propagated when the reference was set; re-save the setting after a minute.
Step 6

Cut off the app's public access

The app still answers on its public hostname from Step 2. Take that away so the only path to it is through the gateway you are about to build. Restrict inbound so it accepts traffic from the gateway subnet and nothing else.

# deny all inbound by default, then allow only the gateway subnet
az webapp config access-restriction add -g "$RG" -n "$APP" \
  --rule-name allow-appgw --priority 100 --action Allow \
  --vnet-name "$VNET" --subnet snet-appgw

# and take the app off public network access as the outer wall
az resource update -g "$RG" -n "$APP" --resource-type "Microsoft.Web/sites" \
  --set properties.publicNetworkAccess=Disabled
Checkpoint The default *.azurewebsites.net hostname now refuses you — a direct hit should fail or time out. Two things enforce that: an access restriction that only trusts the gateway subnet, and public network access switched off. The app is now reachable exactly one way, which is the whole point of the gateway coming next.
Step 7

Application Gateway v2 with WAF as the single door

The gateway is the only thing on this platform with a public IP. It terminates the connection, runs every request through the WAF in prevention mode against the Microsoft-managed ruleset, and forwards survivors to the web app as its backend. Give it a public IP with a DNS label, then create the gateway pointing its backend pool at the app's hostname.

# public IP for the gateway (Standard SKU, static — required by App Gateway v2)
az network public-ip create -g "$RG" -n appgw-pip \
  --sku Standard --allocation-method Static \
  --dns-name "campux-$SUFFIX"

APPHOST=$(az webapp show -g "$RG" -n "$APP" --query defaultHostName -o tsv)

# WAF_v2 gateway: public listener on 80, backend = the web app, WAF in Prevention
az network application-gateway create -g "$RG" -n campux-appgw \
  --sku WAF_v2 --capacity 2 \
  --vnet-name "$VNET" --subnet snet-appgw \
  --public-ip-address appgw-pip \
  --servers "$APPHOST" \
  --http-settings-protocol Https --http-settings-port 443 \
  --frontend-port 80 --priority 100

App Service checks the Host header, so the gateway must send the app's own hostname upstream rather than the gateway's — otherwise every request bounces back a 404. Set the HTTP settings to pick the host name from the backend, and turn the WAF policy to prevention.

# forward the backend's own host header (fixes App Service 404s)
az network application-gateway http-settings update -g "$RG" \
  --gateway-name campux-appgw -n appGatewayBackendHttpSettings \
  --host-name-from-backend-pool true

# WAF: prevention mode, Microsoft-managed OWASP ruleset
az network application-gateway waf-policy create -g "$RG" -n campux-waf
az network application-gateway waf-policy managed-rule rule-set add -g "$RG" \
  --policy-name campux-waf --type OWASP --version 3.2
az network application-gateway waf-policy policy-setting update -g "$RG" \
  --policy-name campux-waf --mode Prevention --state Enabled

WAFID=$(az network application-gateway waf-policy show -g "$RG" -n campux-waf --query id -o tsv)
az network application-gateway update -g "$RG" -n campux-appgw \
  --set firewallPolicy.id="$WAFID"

Now add the real HTTPS listener. The gateway reads its TLS certificate straight from Key Vault, and it authenticates to the vault with a user-assigned managed identity — a standalone identity you attach to the gateway. App Gateway v2 requires a user-assigned identity for Key Vault integration; the system-assigned kind is not supported for this. Create the identity, give it Key Vault Secrets User on the vault (the certificate's private key is stored as a vault secret, so this is the role that reads it), and attach it to the gateway.

# standalone identity the gateway will use to read the cert from Key Vault
az identity create -g "$RG" -n appgw-identity
GWIDID=$(az identity show -g "$RG" -n appgw-identity --query id -o tsv)
GWIDPRIN=$(az identity show -g "$RG" -n appgw-identity --query principalId -o tsv)

# the gateway's identity reads secret values (the cert's private key lives as a secret)
az role assignment create \
  --assignee-object-id "$GWIDPRIN" --assignee-principal-type ServicePrincipal \
  --role "Key Vault Secrets User" --scope "$KVID"

# attach the user-assigned identity to the gateway
az network application-gateway identity assign -g "$RG" \
  --gateway-name campux-appgw --identity "$GWIDID"

Point the gateway at the certificate by its Key Vault secret id — the versionless secret uri for the cert. Then add frontend port 443, an HTTPS listener that uses the SSL cert, and a routing rule that sends that listener to the same backend pool and HTTP settings the port-80 listener already uses.

# the cert's Key Vault SECRET id (not the certificate id) — this is what the gateway consumes
CERTSID=$(az keyvault certificate show --vault-name "$KV" -n appgw-cert --query sid -o tsv)

# register the cert on the gateway, read from Key Vault by the attached identity
az network application-gateway ssl-cert create -g "$RG" \
  --gateway-name campux-appgw -n campux-sslcert \
  --key-vault-secret-id "$CERTSID"

# frontend port 443
az network application-gateway frontend-port create -g "$RG" \
  --gateway-name campux-appgw -n port-443 --port 443

# HTTPS listener bound to that port and cert
az network application-gateway http-listener create -g "$RG" \
  --gateway-name campux-appgw -n https-listener \
  --frontend-port port-443 --ssl-cert campux-sslcert

# route the HTTPS listener to the existing backend pool + http settings
az network application-gateway rule create -g "$RG" \
  --gateway-name campux-appgw -n https-rule --rule-type Basic --priority 110 \
  --http-listener https-listener \
  --address-pool appGatewayBackendPool \
  --http-settings appGatewayBackendHttpSettings
Self-signed in the lab, production in the mechanism

The certificate here is self-signed, so a browser hitting https:// will warn that it is not trusted — a real front door uses a cert issued by a CA and the warning disappears. What is production-real is the wiring: the gateway holds no certificate file of its own, it reads its TLS cert from Key Vault at runtime using an attached managed identity. Swap the self-signed cert for a CA-issued one in the same vault and nothing else about this changes.

Checkpoint The gateway has a public IP, an HTTPS :443 listener reading from Key Vault, and a healthy backend. Confirm the backend shows Healthy, which means the gateway can reach the app and the host header is right:
az network application-gateway show-backend-health -g "$RG" -n campux-appgw \
  --query "backendAddressPools[].backendHttpSettingsCollection[].servers[].health" -o tsv
Step 8

Verify: the store answers, the injection does not

Hit the gateway's public address and you should get the storefront — served through the WAF, from an app that has no public access, backed by a database the internet cannot see. That single successful request is the whole architecture working.

GWHOST=$(az network public-ip show -g "$RG" -n appgw-pip --query dnsSettings.fqdn -o tsv)
curl -s "http://$GWHOST" | head            # the store responds through the WAF

Now prove the WAF is doing more than passing traffic. Send a request with an obvious SQL-injection pattern in the query string; prevention mode should answer 403 and never let it reach the app.

curl -s -o /dev/null -w "%{http_code}\n" \
  "http://$GWHOST/?id=1%20OR%201=1--"       # -> 403, blocked by the managed ruleset
Checkpoint A normal request returns the store; the injection returns 403. The block is logged — in a full build the gateway's diagnostic settings send WAF logs to a Log Analytics workspace, where a KQL query over AzureDiagnostics filtered to ruleSetType and action_s == "Blocked" shows exactly which rule fired and on which request. That log line is the evidence a security reviewer asks for: not "we have a firewall," but "here is the attack it stopped."
Step 9

The pipeline: deploy by OIDC with no stored secret

The old way to let GitHub Actions deploy to Azure was to create a service principal, download its client secret, and paste it into GitHub as a repository secret — a long-lived credential sitting in two places, waiting to leak, needing rotation forever. The current way stores nothing. You register an identity in Entra, attach a federated credential that trusts a specific repo and branch, and at deploy time GitHub hands Azure a short-lived OIDC token that Azure verifies against that trust. No password is created, so none can leak.

# register an Entra app to represent the pipeline, and a service principal for it
APPREG=$(az ad app create --display-name "campux-retail-deploy" --query appId -o tsv)
az ad sp create --id "$APPREG"

# the federated credential: trust ONLY this repo on this branch, no secret involved
az ad app federated-credential create --id "$APPREG" --parameters '{
  "name": "gh-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:kloudcaptain/campux-retail:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'

Give that identity the least privilege it needs — Website Contributor on the resource group is enough to deploy to App Service, and far narrower than Contributor over the whole subscription. Scope it to $RG and nothing wider.

# least-privilege deploy rights, scoped to this resource group only
SPID=$(az ad sp show --id "$APPREG" --query id -o tsv)
SUBID=$(az account show --query id -o tsv)
az role assignment create \
  --assignee-object-id "$SPID" --assignee-principal-type ServicePrincipal \
  --role "Website Contributor" \
  --scope "/subscriptions/$SUBID/resourceGroups/$RG"

The workflow carries three ids — client, tenant, subscription — and every one of them is an identifier, not a credential. They name who is asking; the OIDC token proves it. Put them in GitHub as variables or secrets if you like, but there is nothing secret about them. The id-token: write permission is what lets the job mint the OIDC token that azure/login exchanges.

# .github/workflows/deploy.yml
name: deploy
on:
  push:
    branches: [ main ]
permissions:
  id-token: write        # lets the job request the OIDC token — this is the whole trick
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}          # just an id, not a secret
          tenant-id: ${{ vars.AZURE_TENANT_ID }}          # just an id
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} # just an id
      - uses: azure/webapps-deploy@v3
        with:
          app-name: campux-web-XXXX      # your $APP name
          images: <your-acr>.azurecr.io/campux-retail:latest
Checkpoint A push to main runs the workflow, and the azure/login step logs in with no password anywhere. gh secret list in the repo holds no client secret — there is nothing to rotate and nothing to leak, because the trust is federated to the repo and branch, not carried in a stored credential. If login fails with a subject-mismatch error, the branch or repo in the federated credential's subject does not match the workflow that ran — the trust is deliberately that specific.
Down

Tear it down

This lab runs billable resources, so the teardown matters more than usual. Everything is in one resource group — delete it and confirm.

az group delete -n campux-retail-rg --yes --no-wait
az group exists -n campux-retail-rg      # -> false once the delete completes

# the Entra app registration from Step 9 lives in the tenant, not the group — remove it too
az ad app delete --id "$APPREG"
Checkpoint The group is gone, and with it the App Service plan, the gateway, the Key Vault, and the SQL database that were the only things costing money. One thing lives outside the group: the Entra app registration from Step 9, deleted by the second command above. Do both the moment you finish and this lab is a few cents, not a few dollars.
End

What you can now honestly claim

You deployed a real application container into a production-shaped Azure network: a VNet with subnets purpose-built for a gateway, App Service integration, and private endpoints; a web app with no public access, integrated into the network; an Azure SQL database reachable over nothing but a private endpoint resolved by private DNS; and an Application Gateway with a WAF in prevention mode as the single, inspected way in. You also took the secrets out: the app reads config through a Key Vault reference instead of a stored value, authenticates to SQL with a keyless managed identity rather than a password, terminates HTTPS from a certificate the gateway reads out of Key Vault by identity, and ships through a GitHub Actions OIDC pipeline that stores no credential at all. On a résumé that is "designed and deployed a secret-free, network-secured web application on Azure — WAF-fronted, private App Service and SQL, keyless identity to the data tier, TLS from Key Vault, and a federated-OIDC deploy" — and you can draw the request path from memory, which is what an interviewer is actually checking. The next move is Capstone Plate I, where you add the layers this lab still leaves out — deployment slots, telemetry and alerting, zone redundancy, and Azure Policy governance — the operations skin that turns this build into the thing you put on the table.

Footnotes
  1. Regional VNet integration only routes an app's outbound traffic through the VNet; it needs the target subnet delegated to Microsoft.Web/serverFarms, and one integration per App Service plan. It is what lets the app reach a private endpoint — it does not make the app itself private, which is why Step 6 disables public access separately.
  2. A private endpoint gives a service a private IP inside your subnet, and the paired privatelink.database.windows.net private DNS zone is what makes the service's public hostname resolve to that private IP from inside the VNet. Skip the DNS zone and the app resolves the public name, gets a public IP, and fails to connect — the classic "forgot the DNS step" mistake, made here on purpose and avoided.
  3. The WAF's managed ruleset is the OWASP Core Rule Set; prevention mode blocks matches outright, detection mode only logs them. Start new applications in detection to find false positives, then switch to prevention once the traffic is understood — this lab goes straight to prevention because the injection test is meant to be blocked.