Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 32m2026-06-10

MS Stack Ch 13 — Azure App Service

App Service plans, deployment slots + swap + warmup, app settings, Key Vault references, VNet integration, scale-out rules, custom domains + TLS. The Microsoft PaaS that hosts most of the stack.

Chapter 13 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

Azure App Service is the workhorse PaaS hosting most ASP.NET, Node, Python, Java and PHP workloads inside Microsoft and across its customer base. It abstracts the OS, patches itself on a maintenance cadence, terminates TLS at the edge, integrates with Entra identity and VNet networking, and gives you a deployment-slot primitive that — used well — collapses zero-downtime deploys to a single button. Used badly, it gives you cold-start outages, secrets in plain text, and a Friday-evening pager call when autoscale fights itself.

Shipping-level App Service means: provisioning a plan, deploying an app, configuring app settings, and turning HTTPS on. Expert-level App Service means: writing the Bicep, picking the right tier for the actual workload shape, configuring warmup paths that gate the slot swap, sourcing every secret from Key Vault via managed identity, getting VNet integration and private endpoints to talk to each other correctly, and tuning autoscale so it neither flaps nor over-provisions.

You finish this chapter when you can stand up a production App Service from scratch — plan, app, slots, identity, Key Vault wiring, VNet integration, autoscale, health checks, custom domain with TLS — and explain every choice.

Concepts and depth

App Service Plan vs Web App

The App Service Plan is the compute — a VM-sized billing unit with a SKU (B1, S1, P1v3, etc.), an OS (Linux or Windows), a region, and a worker count. The Web App is the deployment surface — a URL (https://<name>.azurewebsites.net), a sandbox, a set of slots and app settings, and a binding to a Plan. One Plan can host many apps; all apps on the Plan share the same VMs, the same RAM, and the same CPU pool. If your traffic patterns differ wildly across apps (one is a chatty API, another is a nightly batch), they will starve each other on a shared Plan.

A common production layout is one Plan per service tier (a Premium v3 plan for production-traffic apps, a Standard plan for dev/test) with one Web App per logical service. Sharing a plan across two production apps only makes sense when they have correlated, complementary load profiles; otherwise the noisy-neighbour blast radius is the whole tier.

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: 'plan-${env}'
  location: location
  sku: { name: 'P1v3', tier: 'PremiumV3', capacity: 2 }
  kind: 'linux'
  properties: { reserved: true } // 'reserved=true' is the magic word for Linux
}

resource api 'Microsoft.Web/sites@2023-12-01' = {
  name: 'api-${env}'
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|9.0'
      alwaysOn: true
      healthCheckPath: '/health/live'
      minTlsVersion: '1.2'
    }
  }
}
Good enough to ship
  • • One Plan per environment tier.
  • • One Web App per logical service.
  • httpsOnly: true, alwaysOn: true, minTlsVersion: 1.2.
Expert tier
  • • Co-locate apps with complementary load on one Plan deliberately.
  • • Multi-region Plan layout fronted by Front Door.
  • • Zone-redundant Premium v3 for region-resilient prod.

Linux vs Windows runtime; framework-dependent vs self-contained

App Service offers two host OSs. Linux is the default for new workloads — smaller footprint, faster cold start, container-friendly, no IIS quirks. Windows is the right choice when you need .NET Framework (not Core), WCF, full IIS modules, or in-process hosting features that Linux containers cannot replicate. For everything else, prefer Linux.

For .NET workloads, choose between framework-dependent publishes (the runtime is provided by the platform image; your zip ships just your DLLs) and self-contained publishes (you ship the runtime too). Framework-dependent is smaller, faster to deploy, and lets the platform patch the runtime under you. Self-contained pins the runtime version; useful when you have not certified your app on the platform's latest patch yet, or when you need a runtime version newer than what Azure currently provides.

# Framework-dependent — recommended default
dotnet publish -c Release -r linux-x64 --self-contained false -o ./out

# Self-contained — when you must pin the runtime
dotnet publish -c Release -r linux-x64 --self-contained true -o ./out
Good enough to ship
  • • Linux + framework-dependent for any new .NET workload.
  • • Pin runtime via linuxFxVersion: 'DOTNETCORE|9.0'.
  • • Re-deploy when the platform image moves a runtime patch.
Expert tier
  • • Self-contained when bleeding-edge runtime is required.
  • • Custom container when you need OS packages outside the base image.
  • • Windows + WebDeploy when you genuinely need full IIS.

Application Settings as environment variables

Anything you put in Configuration → Application Settings is exposed to the running app as an environment variable. ASP.NET Core's environment-variable configuration provider picks them up automatically; nested keys use __ as the separator (App Service auto-translates : to __ for Linux). So Db:Conn in appsettings.json becomes Db__Conn in App Settings.

[
  { "name": "ASPNETCORE_ENVIRONMENT",     "value": "Production",                                  "slotSetting": true  },
  { "name": "Db__Conn",                   "value": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/db-conn/abc123)", "slotSetting": false },
  { "name": "Jwt__Authority",             "value": "https://login.microsoftonline.com/<tenant>/v2.0", "slotSetting": false },
  { "name": "WEBSITE_RUN_FROM_PACKAGE",   "value": "1",                                           "slotSetting": false }
]

The Configuration blade has two tabs: Application Settings and Connection Strings. Avoid Connection Strings unless you have a legacy .NET Framework app that explicitly reads ConfigurationManager.ConnectionStrings; the Application Settings path is simpler and uniform.

WEBSITE_* system environment variables

App Service injects a family of platform-prefixed environment variables. The useful ones:

  • WEBSITE_HOSTNAME — your default hostname (e.g. api-prod.azurewebsites.net).
  • WEBSITE_SITE_NAME — the Web App name.
  • WEBSITE_INSTANCE_ID — the worker instance ID; log it for per-instance correlation.
  • WEBSITE_RESOURCE_GROUP — useful for emergency self-diagnostics.
  • WEBSITE_RUN_FROM_PACKAGE=1 — switch from regular zip-deploy to read-only ZIP-mount.
  • WEBSITE_VNET_ROUTE_ALL=1 — route all outbound traffic (not just RFC1918) through VNet integration.
  • WEBSITE_TIME_ZONE — set the process time zone (Windows only); Linux uses TZ.
  • WEBSITES_PORT — the port your container listens on (containers only).

Use these for instance-level logging context and to control platform behaviour. Setting WEBSITE_VNET_ROUTE_ALL=1 is the single most common fix for "my app cannot reach api.openai.com after I added VNet integration" — by default, only private address space is routed through the VNet.

"Always On", health-check path, instance count

By default, App Service evicts idle apps after 20 minutes of no traffic; the next request takes the cold-start hit (a JIT-compiled .NET app can take 5–30 seconds to warm up). Always On keeps a heartbeat hitting the root URL every ~5 minutes, preventing eviction. Turn it on for every production app; it costs nothing.

The Health Check feature points App Service at a URL on your app and pings it every minute on every instance. If two consecutive checks fail, that instance is removed from the load balancer. After ten consecutive minutes of failures, the instance is restarted. The path should be the liveness endpoint from chapter 5 (/health/live) — fast, no downstream calls. Pointing it at the readiness path will flap during warmup and cause unnecessary recycling.

Instance count is the worker count on the Plan. Two is the minimum for production HA — one instance gives you a single point of failure during patching. Three or more starts to make sense once your tail-latency budget cares about a single instance being slow.

Good enough to ship
  • • Always On = true on every prod app.
  • • Health Check path = /health/live.
  • • Minimum 2 instances.
Expert tier
  • • Tune WEBSITE_LOAD_USER_PROFILE for app-specific cold-start fixes.
  • • Combine Health Check with custom autoscale triggers.
  • • Use Premium v3 zone-redundant tier for cross-AZ HA.

Cold start, warmup, idle eviction

Cold start is the latency to spin a worker, load the .NET runtime, JIT your app, build the DI container, and start serving. For an average ASP.NET Core app, expect 3–10 seconds on Linux, 10–30 seconds on Windows with IIS. Idle eviction happens after ~20 minutes of no traffic on apps that have Always On disabled; first request pays the full cold-start cost.

The mitigations stack:

  1. Always On — eliminate idle eviction in production.
  2. Slot warmup — guarantee the new code is warm before it takes traffic.
  3. Readiness probe — return 503 until app-internal warmup (DB pool, cache prefetch) is complete.
  4. Pre-JIT (ReadyToRun) at publish<PublishReadyToRun>true</PublishReadyToRun> in csproj; trades publish size for faster startup.
  5. Containerised cold start — pre-pull the image to the worker via WEBSITES_PORT + container startup probe.

Deployment slots and slot swap

A slot is a separate sandbox on the same Plan with its own URL (https://<app>-staging.azurewebsites.net), its own app settings, and its own deployment. The swap is the killer feature: deploy to staging, warm it up, swap — production now serves the staging code instantly, and the staging slot holds the previous production code (so rollback is another swap).

Step 1
Deploy to staging
build + zip-deploy
Step 2
Warmup
ping /health/ready
Step 3
Swap
VIP flip; settings toggle
Step 4
Rollback ready
previous code now on staging
The swap dance: warm-then-flip beats stop-then-start every time.

By default, all app settings swap with the slot. Settings tagged with "Deployment slot setting" stay pinned to the slot. The minimum pin list:

  • ASPNETCORE_ENVIRONMENT — staging stays Staging, prod stays Production.
  • Slot-specific connection strings (a staging-only DB).
  • Slot-specific feature flag overrides.
  • App Insights instrumentation key if you separate telemetry by slot.
<!-- applicationInitialization in App Service Configuration / web.config -->
<applicationInitialization doAppInitAfterRestart="true">
  <add initializationPage="/health/ready" hostName="staging-domain" />
  <add initializationPage="/api/warmup" hostName="staging-domain" />
</applicationInitialization>

App Service hits the listed pages until they return success; the swap waits for warmup. Pair with the readiness probe so warmup completes only after dependencies are reachable.

Good enough to ship
  • • Always deploy to staging first, then swap.
  • • Warmup paths configured.
  • ASPNETCORE_ENVIRONMENT pinned as slot-specific.
Expert tier
  • • Swap-with-preview for two-phase manual cutover.
  • • Auto-swap with health-check gating in a pipeline.
  • • Slot traffic routing for canaries (% of requests to staging).

Log stream and persistent log storage

The Log Stream is the real-time stdout/stderr tail. Either through the portal or via CLI:

az webapp log tail --name api-prod --resource-group rg-prod

Log Stream is ephemeral — when the worker recycles, the stream resets. For durable retention, enable App Service Diagnostic Settings and send platform logs to a Log Analytics workspace or a Storage Account. The relevant log categories:

  • AppServiceHTTPLogs — every HTTP request, IIS-style.
  • AppServiceConsoleLogs — your stdout/stderr.
  • AppServiceAuditLogs — who-did-what in the management plane.
  • AppServiceFileAuditLogs — file changes via Kudu or deployments.
  • AppServicePlatformLogs — restarts, warmup, autoscale events.

Query them with KQL (chapter 12) in Log Analytics. App-level telemetry (request rate, exceptions, dependency calls) belongs in Application Insights (chapter 15), not in App Service Logs.

Key Vault references for secrets in App Settings

The reference syntax is:

@Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<secret>/<version>)
@Microsoft.KeyVault(VaultName=<vault>;SecretName=<secret>)

At app start, App Service uses the app's managed identity to fetch the secret from Key Vault and substitute the value into the environment variable. The reference is re-resolved on a ~24 hour cadence (or immediately on app restart). The Configuration blade shows a green check next to a resolved reference and a red cross next to a broken one — always verify after wiring.

The prerequisites:

  1. The app has a system-assigned or user-assigned managed identity.
  2. The identity has the Key Vault Secrets User role on the vault (RBAC mode) or a get secret permission (legacy access-policy mode).
  3. The vault allows the call: either Azure-trusted-services exception or a private endpoint paired with VNet integration.
  4. The secret URI is correct. Versionless URIs auto-rotate when the secret rotates; versioned URIs are immutable until you change the reference.
// In the app, just read it as a normal env var
var dbConn = builder.Configuration["Db:Conn"];
// Or via strongly-typed options
builder.Services.Configure<DbOptions>(builder.Configuration.GetSection("Db"));

Never paste secrets into App Settings directly. The audit log shows the value; anyone with Reader on the resource group can read them.

Good enough to ship
  • • Every secret behind @Microsoft.KeyVault(...).
  • • Managed identity granted Key Vault Secrets User via RBAC.
  • • Verify the green check in Configuration after wiring.
Expert tier
  • • Versionless URIs + secret rotation policies for auto-rotation.
  • • Cross-region replicas of the vault for DR.
  • • Pre-warm secret cache to avoid first-request rotation spikes.

Networking: access restrictions, private endpoints, VNet integration

App Service has three networking knobs that get conflated:

  • Access restrictions — public-internet allow/deny IP rules and service tag rules. The front door for "only Front Door can reach me", "only my corporate IP block can reach the staging slot", etc.
  • Private endpoints — give the inbound side of the app a private IP in your VNet, optionally disabling all public traffic. The default for any app that is purely an internal API.
  • VNet integration — give the outbound side of the app a foothold in your VNet so it can reach private resources (Storage with private endpoint, SQL with private endpoint, Key Vault with private endpoint).

The combination you want for a fully-private API:

[Front Door public]  →  [Private endpoint on App Service]
                                  │
                                  ▼ (VNet integration outbound)
                         [Private endpoints on Storage, SQL, KV]

Set WEBSITE_VNET_ROUTE_ALL=1 to route every outbound (not just RFC1918) through the VNet — required when your VNet egresses via a firewall or NAT that adds an allowlisted public IP.

Diagnostics blade and metrics

The Diagnose and solve problems blade is the first stop for any production weirdness. It runs canned analytics over your platform logs and surfaces the common patterns: failed deploys, high CPU, memory pressure, slow requests, autoscale flapping. Many issues are diagnosed there in under a minute.

The Metrics blade graphs platform-level metrics: CPU percentage, memory percentage, requests, response time, data in/out, HTTP status counts. These are platform metrics — coarser than App Insights and not correlated with your code, but always available even before App Insights is wired.

For deeper diagnostics, the Kudu debug console (https://<app>.scm.azurewebsites.net) lets you SSH-equivalent into the worker, inspect deployed files, view environment variables (with secrets masked), and download memory dumps. Use it sparingly; it is a debug surface, not an operational one.

Good enough to ship
  • • Open Diagnose-and-solve first.
  • • Metrics dashboard for high-level health.
  • • Log Stream for active debugging.
Expert tier
  • • Kudu memory dumps + dotnet-dump analysis.
  • • Profiler + Snapshot Debugger for prod traces.
  • • Custom alerts wired into action groups + PagerDuty.

Worked examples

Example 1 — Full production Bicep

A minimal but production-grade Bicep for an API with staging slot, managed identity, Key Vault reference, VNet integration and Health Check:

param env string
param location string = 'eastus2'

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: 'plan-${env}'
  location: location
  sku: { name: 'P1v3', tier: 'PremiumV3', capacity: 2 }
  kind: 'linux'
  properties: { reserved: true }
}

resource api 'Microsoft.Web/sites@2023-12-01' = {
  name: 'api-${env}'
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    virtualNetworkSubnetId: integrationSubnet.id
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|9.0'
      alwaysOn: true
      healthCheckPath: '/health/live'
      minTlsVersion: '1.2'
      ftpsState: 'Disabled'
      appSettings: [
        { name: 'ASPNETCORE_ENVIRONMENT', value: env }
        { name: 'Db__Conn',               value: '@Microsoft.KeyVault(VaultName=kv-${env};SecretName=db-conn)' }
        { name: 'WEBSITE_VNET_ROUTE_ALL', value: '1' }
        { name: 'WEBSITE_RUN_FROM_PACKAGE', value: '1' }
      ]
    }
  }
}

resource stagingSlot 'Microsoft.Web/sites/slots@2023-12-01' = {
  parent: api
  name: 'staging'
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|9.0'
      alwaysOn: true
      healthCheckPath: '/health/live'
      autoSwapSlotName: ''  // disable auto-swap; do it explicitly in pipeline
    }
  }
}
  • One Plan (Premium v3, capacity 2 for HA).
  • API with system-assigned identity, HTTPS-only, Always On, health-check path, Linux .NET 9.
  • App settings include Key Vault reference; routes all outbound through VNet.
  • Staging slot inherits the plan and has its own identity.

Example 2 — Slot swap with health-check gate from GitHub Actions

name: Deploy API
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '9.0.x' }
      - run: dotnet publish src/Api -c Release -o ./out
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - uses: azure/webapps-deploy@v3
        with:
          app-name: api-prod
          slot-name: staging
          package: ./out
      - name: Wait for staging readiness
        run: |
          for i in {1..30}; do
            if curl -fsS https://api-prod-staging.azurewebsites.net/health/ready; then
              echo "Ready"; exit 0
            fi
            sleep 10
          done
          echo "Staging never went ready"; exit 1
      - name: Swap slots
        run: az webapp deployment slot swap \
              --resource-group rg-prod \
              --name api-prod \
              --slot staging \
              --target-slot production
  • Federated identity (id-token: write) — no client secret in repo.
  • Deploy to staging, poll readiness, swap on success.
  • If readiness never reports, the pipeline fails and traffic continues on the previous prod code.

Example 3 — Autoscale with custom metric

A simple CPU-based autoscale via Bicep, with a schedule profile for known-busy hours:

resource autoscale 'Microsoft.Insights/autoscalesettings@2022-10-01' = {
  name: 'autoscale-api'
  location: location
  properties: {
    targetResourceUri: plan.id
    enabled: true
    profiles: [
      {
        name: 'default'
        capacity: { minimum: '2', maximum: '10', default: '2' }
        rules: [
          {
            metricTrigger: { metricName: 'CpuPercentage', metricResourceUri: plan.id,
              timeGrain: 'PT1M', statistic: 'Average', timeWindow: 'PT5M',
              timeAggregation: 'Average', operator: 'GreaterThan', threshold: 70 }
            scaleAction: { direction: 'Increase', type: 'ChangeCount', value: '1', cooldown: 'PT5M' }
          }
          {
            metricTrigger: { metricName: 'CpuPercentage', metricResourceUri: plan.id,
              timeGrain: 'PT1M', statistic: 'Average', timeWindow: 'PT10M',
              timeAggregation: 'Average', operator: 'LessThan', threshold: 30 }
            scaleAction: { direction: 'Decrease', type: 'ChangeCount', value: '1', cooldown: 'PT10M' }
          }
        ]
      }
    ]
  }
}
  • Scale-out faster than scale-in (5 min vs 10 min cooldown) — better to over-provision than under-serve.
  • Min 2 instances enforces HA.
  • Max 10 caps spend; add a budget alert at 80% of plan-cost cap.

Example 4 — Diagnostic Settings + KQL on platform logs

resource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
  scope: api
  name: 'to-log-analytics'
  properties: {
    workspaceId: workspace.id
    logs: [
      { category: 'AppServiceHTTPLogs', enabled: true }
      { category: 'AppServiceConsoleLogs', enabled: true }
      { category: 'AppServicePlatformLogs', enabled: true }
      { category: 'AppServiceAuditLogs', enabled: true }
    ]
    metrics: [{ category: 'AllMetrics', enabled: true }]
  }
}

Then in Log Analytics:

AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| summarize
    Total = count(),
    Errors = countif(ScStatus >= 500),
    p95 = percentile(TimeTaken, 95)
  by bin(TimeGenerated, 1m), CsHost
| order by TimeGenerated desc
  • Sends platform logs to Log Analytics (queryable via KQL).
  • Sample query — error rate and p95 latency per minute per hostname.

Hands-on exercises

  1. Provision and deploy. Create a P1v3 Linux Plan, an App Service, and a staging slot via Bicep. Deploy a hello-world ASP.NET Core 9 app to staging, then swap.

    • You are done when production serves the new code and the previous version is on staging.
  2. Key Vault wiring. Create a Key Vault, store one secret, give the App Service's managed identity Key Vault Secrets User. Reference the secret from an App Setting and verify it resolves in IConfiguration.

    • You are done when the green check shows in Configuration and your app reads the value at startup.
  3. Health Check + recycle. Enable Health Check on /health/live. Deploy a broken version that returns 500 from that endpoint; observe the unhealthy instance removed from the load balancer within 2 minutes.

    • You are done when you have observed the failure-then-recovery in the Health Check blade.
  4. Autoscale. Add the autoscale rule from Example 3. Use k6 or hey to drive load until CPU exceeds 70%; observe scale-out within five minutes.

    • You are done when instance count rises and then falls back.
  5. Private networking. Provision a Storage account with a private endpoint. Verify the app cannot reach it. Add VNet integration to the App Service, set WEBSITE_VNET_ROUTE_ALL=1, retry — it should now work.

    • You are done when you can articulate exactly which knob unblocked traffic.
  6. Pipeline deploy. Wire Example 2 into a GitHub Actions workflow against your real subscription using federated identity. Confirm zero secrets are stored in the repo.

    • You are done when a push to main deploys → polls readiness → swaps.

Self-check questions

  1. Explain the difference between an App Service Plan and a Web App, and when you would share a plan across apps.
  2. What does "Deployment slot setting" mean, and which three settings should always be pinned?
  3. Describe the slot-swap sequence in five steps.
  4. What does WEBSITE_VNET_ROUTE_ALL=1 change, and when do you need it?
  5. Why is /health/live the right Health Check path and /health/ready the wrong one?
  6. What happens after 2 consecutive Health Check failures? After 10 consecutive minutes?
  7. Explain the prerequisites for @Microsoft.KeyVault(...) to resolve successfully.
  8. When is self-contained .NET publishing the right choice over framework-dependent?
  9. Why scale-out over scale-up for stateless apps? When would you scale up?
  10. What is the difference between Access Restrictions, Private Endpoints, and VNet Integration?
  11. Why is Kudu a debug surface rather than an operational one?
  12. Describe the cold-start mitigations and the order to apply them.

High-signal resources

Official docs

Books or courses

  • Microsoft Azure for .NET Developers — covers the App Service surface end to end.
  • Pluralsight Azure App Service Deep Dive — solid four-hour walkthrough.

Practitioner posts

Weekly milestones

  1. Day 1. Read the overview + best practices. Provision the plan + app + slot (exercise 1). Self-check questions 1–3.
  2. Day 2. Key Vault + managed identity wiring (exercise 2). Self-check questions 4 + 7.
  3. Day 3. Health Check + autoscale (exercises 3 + 4). Self-check questions 5–6 + 9.
  4. Day 4-5. Private networking (exercise 5). Self-check question 10.
  5. Day 6-7. Pipeline deploy with federated identity (exercise 6). Self-check questions 8 + 11 + 12.

How it shows up in the capstone

The capstone's API and SPA both run on App Service. The API uses a P1v3 Linux Plan with capacity 2, a production slot, a staging slot, and Always On. The SPA is a separate App Service on the same Plan (the load profile is gentle and complementary). Every secret — database connection, Kusto endpoint, signing keys — lives in Key Vault and is referenced via @Microsoft.KeyVault(...); the app's system-assigned managed identity has Key Vault Secrets User on the vault.

VNet integration is enabled and WEBSITE_VNET_ROUTE_ALL=1. SQL, Storage, Key Vault and the Kusto cluster all have private endpoints in the same VNet, so the API never traverses the public internet to reach a backend. Health Check points at /health/live. Autoscale runs CPU-based rules with a schedule profile for known-busy hours. Deploys go via GitHub Actions with federated identity → staging → readiness poll → swap.

Previous chapter → Ch 12 — Kusto / KQL Next chapter → Ch 14 — Identity for cloud apps