Terraform Lesson 43 of 89

Terraform on Azure: App Service Plans, Web Apps, Deployment Slots & Custom Domains

You have a web app — an API, a storefront, an admin console — and you want it running on a managed platform where you never patch an OS, never build an AMI, and never think about a load balancer. That is Azure App Service: platform-as-a-service web hosting where you rent a pool of workers (an App Service plan), drop your code or container onto it (a Web App), and Azure handles the fleet, the front-end load balancer, TLS termination and scaling. The portal makes the first deploy feel effortless. It also makes the second, third and hundredth deploy an unversioned pile of clicks that nobody can reproduce, review, or roll back. This lesson replaces the clicking with Terraform — every plan, app, slot, secret reference, identity and certificate declared in HCL, planned before it is applied, and destroyable in one command.

We build the real thing, end to end: an azurerm_service_plan sized by SKU, a azurerm_linux_web_app running a container with app settings and connection strings, a Key Vault reference so the database password is never in state as plaintext, a system-assigned managed identity that reaches Key Vault, SQL and Storage with zero stored credentials, a staging slot you swap into production for blue-green releases, and a custom domain fronted by a free managed TLS certificate. Then we init → plan → apply, curl the live hostname to prove it works, and terraform destroy so the lab costs you rupees, not thousands. Along the way we cover the Windows siblings, Function Apps as the serverless cousin, VNet integration, autoscale, and the traps that bite everyone the first time: a container that App Service probes on the wrong port, a Key Vault reference that resolves to @Microsoft.KeyVault(...) literally because the identity was denied, a slot swap that carries the wrong settings, and a managed cert that never issues because the domain was never verified.

By the end you will hold a copy-pasteable module you can point at any environment, and — more importantly — a mental model of which App Service knob maps to which azurerm argument, so you stop translating portal blades in your head and start reading the resource schema like a native.

What you’ll build

The scenario is the most common one in the Azure world: a containerised web application that needs a production URL on your own domain, a safe release path (deploy to staging, verify, swap), and secrets that never live in the app’s config in plaintext. Portal-first teams end up with a plan, an app, a slot and a certificate that four different people created on four different afternoons, none of it in version control. We declare the whole stack once.

Concretely, Terraform will create: a resource group; a Standard (S1) Linux App Service plan (Standard because that is the cheapest tier that has deployment slots); a Linux Web App running a public container image, with always_on enabled, a health-check path, HTTP/2, an app setting that is a Key Vault reference, and a system-assigned managed identity; a Key Vault (RBAC-mode) holding the database password, with a role assignment granting the app’s identity Key Vault Secrets User; a staging deployment slot; and — optionally, gated behind a variable — a custom hostname binding plus a free App Service managed certificate. The blue-green swap itself is not a Terraform resource (Terraform manages desired state; a swap is an imperative operation) — we trigger it with the Azure CLI or a pipeline, which is exactly how it is done in production.

Why Terraform rather than the portal, the az CLI, or ARM/Bicep? Each has a place, but for repeatable multi-environment web hosting Terraform wins on the axes that matter:

Approach Repeatable? Plan/preview before change Drift detection Multi-cloud Best for
Azure Portal No — clicks aren’t code No No No Learning, one-off experiments
az CLI scripts Partly (imperative, order-dependent) No (runs immediately) No No Glue, one-shot ops, the swap
ARM / Bicep Yes what-if (Azure-only) Weak No Azure-only shops standardised on Bicep
Terraform (azurerm) Yes terraform plan (rich diff) Yes (plan shows drift) Yes (same tool, all clouds) Reusable, reviewed, multi-env infra

Here is the component-to-resource map — the whole lesson in one table, so you know where every piece lands before we write a line:

What it is (Azure concept) Terraform resource Key arguments you’ll set
Compute you rent (workers + SKU) azurerm_service_plan os_type, sku_name, worker_count
The web app itself (Linux) azurerm_linux_web_app service_plan_id, site_config, app_settings, identity
The web app itself (Windows) azurerm_windows_web_app same shape, Windows runtimes
Config & secrets app_settings, connection_string, sticky_settings KV references, sticky flags
Keyless auth identity {} + azurerm_role_assignment SystemAssigned, RBAC roles
Blue-green slot azurerm_linux_web_app_slot app_service_id, slot app_settings
Custom domain azurerm_app_service_custom_hostname_binding hostname, app_service_name
Free TLS cert azurerm_app_service_managed_certificate + ..._certificate_binding custom_hostname_binding_id, ssl_state
Serverless sibling azurerm_linux_function_app service_plan_id (Y1/EP1), storage_account_*
Scaling azurerm_monitor_autoscale_setting target_resource_id, rules

Left-to-right Azure App Service architecture provisioned by Terraform: the Terraform/state plane applies an App Service Plan sized by SKU, which hosts a Linux Web App with a system-assigned managed identity, a staging deployment slot for blue-green swaps, and VNet integration; the app binds a custom domain with a managed TLS certificate and pulls secrets from Key Vault via its managed identity

Read the diagram left to right: Terraform applies the plan (which sizes every app on it), the plan hosts the Web App with its staging slot and VNet integration, the app binds a custom domain + TLS, and — the security spine — the app’s managed identity reads secrets from Key Vault so no password is ever written into app config. That single architecture is what the rest of this lesson builds, argument by argument.

App Service plans: the compute you rent

An App Service plan (azurerm_service_plan) is the set of VM workers your apps run on. It has an operating system (os_type), a SKU (sku_name, which fixes vCPU, RAM, feature set and price) and a worker count (worker_count, how many instances). Every Web App and Function App you place on a plan shares its capacity — this is the single most important cost lever in App Service, because you pay for the plan, not per app. Twenty small apps on one S1 plan cost one S1; twenty S1 plans cost twenty times as much.

resource "azurerm_service_plan" "web" {
  name                = "plan-kvn-web-prod"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location

  os_type  = "Linux"   # "Linux" | "Windows" | "WindowsContainer"
  sku_name = "S1"      # Standard — cheapest tier WITH deployment slots

  worker_count           = 2      # instances behind the front end (HA)
  zone_balancing_enabled = false  # spread instances across AZs (Pv2/Pv3 only)

  tags = local.tags
}

os_type is set once and cannot change — a Linux plan hosts Linux apps and Linux containers; a Windows plan hosts Windows apps; WindowsContainer is its own thing for Windows containers. Linux plans are cheaper for the same SKU letter and are the default choice unless you need a Windows-only runtime (classic .NET Framework, some COM dependencies).

The SKU is where the real decisions live. Below is the tier table you will return to constantly — what each tier gives you, and specifically the two features this lesson depends on: deployment slots (blue-green) and Always On (no cold starts). Note the trap in the Basic row:

SKU (sku_name) Tier vCPU / RAM Deployment slots Scale Always On Custom domain + TLS ~List $/mo Use for
F1 Free shared / 1 GB 0 none No No $0 Throwaway experiments
B1 Basic 1 / 1.75 GB 0 manual → 3 Yes Yes ~$13 Dev/test, small internal apps
S1 Standard 1 / 1.75 GB 5 autoscale → 10 Yes Yes ~$70 Prod apps needing slots
P0v3 Premium v3 1 / 4 GB 20 autoscale → 30 Yes Yes ~$95 Prod, memory-hungry
P1v3 Premium v3 2 / 8 GB 20 autoscale → 30 Yes Yes ~$135 Prod, pre-warmed, VNet
I1v2 Isolated v2 2 / 8 GB 20 autoscale → 100 Yes Yes ~$390 ASE, regulated/isolated

The row everyone learns the hard way is Basic: B1 gives you Always On and a custom domain, but zero deployment slots. If your lesson (or your prod release process) depends on a staging slot and a swap, you need Standard (S1) or higher. That is exactly why the demo below uses S1, not the cheaper B1. Here is how the plan’s own scaling arguments behave:

Argument What it controls Default Notes
worker_count Number of instances (manual scale-out) 1 Run ≥2 in prod so one restart isn’t an outage
per_site_scaling_enabled Let individual apps scale independently of the plan false Rarely needed; complicates capacity math
zone_balancing_enabled Spread instances across availability zones false Premium v2/v3 only; needs worker_count ≥ 3 for real AZ spread
maximum_elastic_worker_count Ceiling for Elastic Premium (Functions) n/a Only on EP* plans

Manual worker_count is fine for a fixed baseline; for demand-driven scaling you attach an autoscale setting to the plan (covered later). The mental model: the plan is the wallet and the capacity ceiling; the apps are tenants that share it.

Web Apps: Linux and Windows

A Web App (azurerm_linux_web_app or azurerm_windows_web_app) is one application running on a plan. The two resources have an almost identical schema — the differences are the runtimes inside site_config.application_stack and a few Windows-only knobs. You reference the plan by ID, declare how the app should run in site_config, and that is the skeleton:

resource "azurerm_linux_web_app" "app" {
  name                = "app-kvn-web-prod"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_service_plan.web.location
  service_plan_id     = azurerm_service_plan.web.id

  https_only = true   # redirect HTTP→HTTPS at the platform

  site_config {
    always_on         = true            # keep a warm worker resident (B1+)
    http2_enabled     = true
    minimum_tls_version = "1.2"
    ftps_state        = "Disabled"      # kill FTP; deploy via zip/CI only
    health_check_path = "/healthz"      # per-instance liveness probe
    worker_count      = 2

    application_stack {
      # Container path — a public image, no registry auth needed:
      docker_registry_url = "https://mcr.microsoft.com"
      docker_image_name   = "azuredocs/aci-helloworld:latest"
    }
  }

  identity {
    type = "SystemAssigned"   # the app gets an AAD identity (used below)
  }

  tags = local.tags
}

The heart of a Web App is site_config. It is a big block; these are the arguments you actually set and why each one matters:

site_config argument What it does Sensible prod value Why it matters
always_on Keeps a worker warm so idle apps don’t cold-start true Off → ~20-min idle unload → 10–60 s first request
application_stack {} The runtime or container image your stack Wrong/empty → app won’t start
health_check_path Path probed per instance; failing instances evicted /healthz Bad path evicts the whole fleet → 503
http2_enabled Enable HTTP/2 true Faster multiplexed connections
minimum_tls_version Lowest TLS the front end accepts "1.2" Compliance; block legacy TLS
ftps_state FTP/FTPS deployment endpoint "Disabled" FTP is a credential-leak surface
worker_count Per-app instance count match plan Overrides plan default per app
vnet_route_all_enabled Route all outbound through the VNet true (with integration) Needed for private egress / NAT GW
ip_restriction {} Inbound allow/deny rules as needed Lock the app to a front door / VNet
app_command_line Startup command (Linux) stack-specific Override the container/runtime entrypoint

For the runtime (non-container) path, you set exactly one language inside application_stack. The Linux options:

Stack application_stack key Example value Notes
Node.js node_version "20-lts" Built-in; reads PORT env var
Python python_version "3.12" Gunicorn/uvicorn; set app_command_line
.NET dotnet_version "8.0" Cross-platform on Linux
Java java_version + java_server "17" + "JAVA"/"TOMCAT" Also java_server_version
PHP php_version "8.3" Built-in NGINX + PHP-FPM
Go / Ruby go_version / ruby_version "1.22" / "3.3" Fewer built-ins; container often cleaner
Custom container docker_registry_url + docker_image_name see demo Set WEBSITES_PORT if not 80

Linux vs Windows, so you pick the right resource:

azurerm_linux_web_app azurerm_windows_web_app
Plan os_type Linux Windows
Runtimes Node/Python/.NET/Java/PHP/Go/Ruby + containers .NET (incl. Framework), Node, Java, Python (limited)
Container support First-class (Docker) Windows containers (via WindowsContainer plan)
Cost (same SKU) Lower Higher (Windows licensing)
Startup override app_command_line app_command_line
When to use Default; anything cross-platform Classic .NET Framework, Windows-only deps

The rule: default to Linux; reach for Windows only when a dependency forces it. The rest of this lesson uses azurerm_linux_web_app, but every pattern (settings, identity, slots, domains) is identical on the Windows resource.

App settings, connection strings & Key Vault references

Configuration reaches your app three ways, and the distinction is exam-worthy because they surface differently inside the app and behave differently on a slot swap.

App settings (app_settings) are a string map exposed as environment variables to your app. Connection strings (connection_string blocks) are typed (SQL, MySQL, etc.) and exposed with a provider-specific prefix on Windows. Key Vault references are app settings whose value is a special token — @Microsoft.KeyVault(...) — that App Service resolves at runtime using the app’s managed identity, so the secret’s plaintext never appears in your Terraform config, your state, or the app’s settings blade.

resource "azurerm_linux_web_app" "app" {
  # ... (as above) ...

  app_settings = {
    "WEBSITES_PORT"                        = "80"
    "WEBSITE_HEALTHCHECK_MAXPINGFAILURES"  = "5"
    "APP_ENV"                              = "production"

    # Key Vault reference — resolved via the app's managed identity.
    # The value in state is the reference token, NOT the secret.
    "DB_PASSWORD" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.db_password.versionless_id})"
  }

  connection_string {
    name  = "AppDb"
    type  = "SQLAzure"
    value = "Server=tcp:sql-kvn.database.windows.net;Database=appdb;Authentication=Active Directory Default;"
  }

  sticky_settings {
    # These names DO NOT swap between slots — they stay put.
    app_setting_names       = ["APP_ENV"]
    connection_string_names = ["AppDb"]
  }
}

Here is how the three configuration mechanisms differ:

Mechanism Terraform Exposed to app as Plaintext in state? Swaps between slots?
App setting app_settings = { K = V } Environment variable Yes (avoid for secrets) Yes, unless in sticky_settings
Connection string connection_string {} Env var w/ type prefix Yes (avoid for secrets) Yes, unless sticky
Key Vault reference app_settings value = @Microsoft.KeyVault(...) Resolved secret value No — only the reference The reference swaps; resolves per-slot identity

The Key Vault reference format has two accepted shapes; know both because error messages quote them:

Format Example Behaviour
SecretUri (versionless) @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/db-pass) Always resolves the latest version — best for rotation
SecretUri (versioned) @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/db-pass/abcd123...) Pins one version — no auto-rotate
VaultName + SecretName @Microsoft.KeyVault(VaultName=kv;SecretName=db-pass) Equivalent shorthand

Use versionless_id from the Terraform secret resource for the rotation-friendly form, as the demo does. Two things must be true for a reference to resolve, and both are common failure points we’ll hit in troubleshooting: the app must have a managed identity, and that identity must be granted read access to the vault (RBAC Key Vault Secrets User, or an access policy with get). Miss either and the app literally sees the string @Microsoft.KeyVault(...) as its config value — which usually crashes the app at boot with a bewildering parse error.

Sticky settings solve a real slot-swap hazard. When you swap staging into production, by default all app settings and connection strings swap with the app. That is usually what you want — except for the handful that must stay tied to their environment (a slot-specific feature flag, an environment name, a per-slot connection string). Listing those names in sticky_settings pins them to the slot so they never travel across a swap. For deeper Key Vault, SQL and DNS coverage, see the companion Terraform on Azure: Key Vault, SQL Database & DNS lesson.

Managed identity: keyless access to Key Vault, SQL & Storage

The identity {} block gives your Web App an Azure AD (Entra) identity — an on-platform principal your app authenticates as, with no secret to store, rotate or leak. This is the backbone of every “keyless” pattern on Azure: the app proves who it is to Azure AD, gets a token, and calls Key Vault / SQL / Storage / Service Bus with that token. There are two flavours:

Identity type Terraform Lifecycle Use when
System-assigned identity { type = "SystemAssigned" } Tied to the app; deleted with it One app, one identity — simplest
User-assigned type = "UserAssigned" + identity_ids = [...] Standalone resource, shareable Many apps share one identity, or pre-grant before app exists
Both type = "SystemAssigned, UserAssigned" Mixed Migration, or one shared + one per-app

System-assigned is the default choice for a single app. The identity’s principal_id is an output you feed into role assignments:

# Grant the app's identity read access to Key Vault secrets (RBAC-mode vault).
resource "azurerm_role_assignment" "app_kv_secrets" {
  scope                = azurerm_key_vault.this.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_linux_web_app.app.identity[0].principal_id
}

The same identity, different role, reaches other services. The roles you’ll grant most often for keyless access:

Target service RBAC role (role_definition_name) What it grants
Key Vault (secrets) Key Vault Secrets User Read secret values (for @Microsoft.KeyVault refs)
Key Vault (certs) Key Vault Certificate User Read certificates
Storage (blobs) Storage Blob Data Contributor Read/write blobs keylessly
Storage (queues) Storage Queue Data Contributor Read/write queue messages
Azure SQL (DB-side) CREATE USER ... FROM EXTERNAL PROVIDER AAD auth — no SQL password
Container Registry AcrPull Pull images without admin creds
Service Bus Azure Service Bus Data Sender/Receiver Send/receive without SAS keys

Note the SQL row is different: Azure SQL authorises AAD identities inside the database with a CREATE USER [app-name] FROM EXTERNAL PROVIDER T-SQL statement (run once, often via azurerm_mssql_database’s AAD admin plus a null_resource/pipeline step), not via an Azure RBAC role. The connection string then uses Authentication=Active Directory Default with no password — which is exactly the connection string in the previous section. This is how you get a Web App talking to SQL with zero credentials anywhere.

A subtle but critical gotcha: the identity’s principal_id does not exist until the app is created, so the role assignment implicitly depends on the app. And RBAC grants take 30–120 seconds to propagate. If your app boots the instant the role assignment returns, the first Key Vault reference resolution can fail even though Terraform succeeded. In production you handle this with a health-check that tolerates a cold secret, or an app-setting change that restarts the app after propagation.

Deployment slots & blue-green swaps

A deployment slot (azurerm_linux_web_app_slot) is a full, live copy of your app running on the same plan, with its own hostname (app-kvn-web-prod-staging.azurewebsites.net), its own settings, and its own warm workers. You deploy to the staging slot, smoke-test it on its own URL, and then swap — Azure re-points the production hostname to the staging workers (already warm) and the old production workers become the new staging. That is blue-green deployment with near-zero downtime, and it is the single biggest reason to use App Service over a plain VM.

resource "azurerm_linux_web_app_slot" "staging" {
  name           = "staging"
  app_service_id = azurerm_linux_web_app.app.id   # parent app, by ID

  https_only = true

  site_config {
    always_on         = true
    health_check_path = "/healthz"
    application_stack {
      docker_registry_url = "https://mcr.microsoft.com"
      docker_image_name   = "azuredocs/aci-helloworld:latest"
    }
  }

  app_settings = {
    "WEBSITES_PORT" = "80"
    "APP_ENV"       = "staging"   # sticky on the parent, so this stays in staging
  }

  identity {
    type = "SystemAssigned"   # slots get their OWN identity — grant it separately!
  }
}

Two facts here trip up everyone. First, the slot references its parent with app_service_id (the full resource ID), not a name. Second — and this is the one that causes 3 a.m. incidents — a slot has its own managed identity. The principal_id of the production app and the staging slot are different, so a Key Vault role assignment on the production identity does not cover the slot. You must grant the slot’s identity separately, or the app works in production and dies the moment you swap staging in.

The swap is not a Terraform resource. Terraform manages desired state; a swap is an imperative, point-in-time operation. You run it with the CLI or a pipeline:

# Blue-green swap: staging → production (warms staging, then re-points prod)
az webapp deployment slot swap \
  --name app-kvn-web-prod \
  --resource-group rg-kvn-appsvc-demo \
  --slot staging --target-slot production

What actually happens during a swap, and the arguments/settings that govern it:

Slot / swap concept What it means Terraform / CLI control Gotcha
Slot A live app copy on the same plan azurerm_linux_web_app_slot Needs Standard+ (S1) SKU
Slot count How many slots the SKU allows plan SKU Basic = 0, Standard = 5, Premium = 20
Swap Re-point prod hostname to slot’s warm workers az webapp deployment slot swap Not a TF resource — imperative
Slot-specific setting Setting that stays with the slot on swap sticky_settings on the app Forget it → prod config leaks to staging
Warm-up before swap Ping a path before completing the swap WEBSITE_SWAP_WARMUP_PING_PATH app setting Prevents cold-start on the swapped-in workers
Slot identity The slot’s own managed identity identity {} on the slot Must be granted RBAC separately from prod
Auto-swap Auto-swap a slot into prod on deploy auto_swap_slot_name in slot site_config Handy for CD, risky without warm-up

Which settings travel and which stay, at a glance:

On swap, this… …swaps with the app …stays with the slot
Normal app_settings Yes
Normal connection_string Yes
Setting named in sticky_settings Yes
Managed identity No — each slot keeps its own Yes
Custom domain binding No — bound to the production app Yes (prod hostname stays prod)
Public certificate / TLS Stays with prod hostname

Driving the swap from a pipeline (deploy to staging → automated smoke test → swap → verify → auto-rollback by swapping back on failure) is the production-grade release pattern; the Azure DevOps Pipelines: CI/CD for Terraform lesson wires exactly that.

Custom domains & TLS

Two resources turn app-kvn-web-prod.azurewebsites.net into www.kloudvin.com with a padlock. First, a custom hostname binding (azurerm_app_service_custom_hostname_binding) tells App Service “this app answers for this hostname.” Second, a managed certificate (azurerm_app_service_managed_certificate) issues a free, auto-renewing TLS cert for that hostname, and a certificate binding attaches it with SNI.

Before any of that, Azure must verify you own the domain, via DNS records. For a subdomain (www) you add a CNAME to the app’s default hostname plus a TXT record at asuid.www containing the app’s custom_domain_verification_id. For the apex (kloudvin.com, no subdomain) you use an A record to the app’s inbound IP plus the same asuid TXT.

# 1. DNS: prove ownership + route traffic (Azure DNS zone shown; any DNS works)
resource "azurerm_dns_cname_record" "www" {
  name                = "www"
  zone_name           = azurerm_dns_zone.this.name
  resource_group_name = var.dns_zone_rg
  ttl                 = 300
  record              = azurerm_linux_web_app.app.default_hostname
}

resource "azurerm_dns_txt_record" "asuid_www" {
  name                = "asuid.www"
  zone_name           = azurerm_dns_zone.this.name
  resource_group_name = var.dns_zone_rg
  ttl                 = 300
  record { value = azurerm_linux_web_app.app.custom_domain_verification_id }
}

# 2. Bind the hostname to the app (after DNS resolves + verifies)
resource "azurerm_app_service_custom_hostname_binding" "www" {
  hostname            = "www.kloudvin.com"
  app_service_name    = azurerm_linux_web_app.app.name
  resource_group_name = azurerm_resource_group.this.name

  depends_on = [azurerm_dns_cname_record.www, azurerm_dns_txt_record.asuid_www]

  # The managed cert (below) will manage ssl_state/thumbprint — ignore drift:
  lifecycle { ignore_changes = [ssl_state, thumbprint] }
}

# 3. Free managed TLS certificate for that hostname
resource "azurerm_app_service_managed_certificate" "www" {
  custom_hostname_binding_id = azurerm_app_service_custom_hostname_binding.www.id
}

# 4. Attach the cert with SNI
resource "azurerm_app_service_certificate_binding" "www" {
  hostname_binding_id = azurerm_app_service_custom_hostname_binding.www.id
  certificate_id      = azurerm_app_service_managed_certificate.www.id
  ssl_state           = "SniEnabled"
}

The lifecycle { ignore_changes = [ssl_state, thumbprint] } on the binding is a well-known necessity: the certificate binding sets ssl_state/thumbprint out-of-band, and without ignore_changes Terraform fights it on every plan. Here is how the domain/TLS pieces relate:

Piece Resource Depends on Note
Ownership proof asuid TXT record Value = custom_domain_verification_id
Traffic routing CNAME (www) / A (apex) www → default_hostname; apex → inbound IP
Hostname binding azurerm_app_service_custom_hostname_binding DNS above hostname + app_service_name
Free TLS cert azurerm_app_service_managed_certificate the binding Auto-renews; free; SNI only
Cert attachment azurerm_app_service_certificate_binding cert + binding ssl_state = "SniEnabled"

Apex versus www, since the DNS differs:

Aspect www (subdomain) Apex / naked (kloudvin.com)
Routing record CNAME → default_hostname A → app inbound IP (+ optional asverify CNAME)
Verification TXT at asuid.www TXT at asuid (apex)
Managed cert Supported Supported (was historically limited)
Gotcha CNAME can’t coexist with other apex records Inbound IP can change if you delete/recreate the app

Certificate options, so you pick the right one:

Cert option Terraform Cost Renewal Use when
App Service managed cert azurerm_app_service_managed_certificate Free Automatic Standard public HTTPS — default choice
Key Vault certificate azurerm_app_service_certificate (key_vault_secret_id) Cert cost Managed in KV Wildcards, EV, centralised cert mgmt
Uploaded PFX azurerm_app_service_certificate (pfx_blob) Cert cost Manual Bring-your-own cert

For most apps the free managed certificate is the answer — it just works, renews itself, and costs nothing. Reach for a Key Vault certificate when you need a wildcard (*.kloudvin.com, which the managed cert doesn’t cover) or centralised certificate governance. If your app instead sits behind an Application Gateway or Front Door doing TLS termination and WAF, that is a different topology — see Terraform on Azure: Load Balancer, Application Gateway & WAF.

VNet integration & private endpoints

Two networking features come up constantly and are worth a paragraph even though they’re not the focus. Regional VNet integration gives your app a foot in a subnet so its outbound calls (to a database, another service, a NAT Gateway) travel over private networking instead of the shared platform IP — set virtual_network_subnet_id on the app and vnet_route_all_enabled = true in site_config. A private endpoint is the inbound complement: it gives the app a private IP in your VNet so it can be reached only from inside the network, taking it off the public internet entirely.

resource "azurerm_linux_web_app" "app" {
  # ...
  virtual_network_subnet_id = azurerm_subnet.app_integration.id  # outbound → VNet
  site_config {
    vnet_route_all_enabled = true   # route ALL outbound through the VNet
    # ...
  }
}
Feature Direction What it does Terraform Needs
VNet integration Outbound App’s egress uses a delegated subnet virtual_network_subnet_id + vnet_route_all_enabled Delegated subnet, Basic+
Private endpoint Inbound App reachable only via private IP azurerm_private_endpoint (subresource sites) Private DNS zone, Premium recommended

The integration subnet must be delegated to Microsoft.Web/serverFarms and used by nothing else. Private endpoints additionally need a private DNS zone (privatelink.azurewebsites.net) so the app name resolves to the private IP inside the VNet.

Function Apps: the serverless sibling

A Function App (azurerm_linux_function_app) is App Service’s serverless cousin — same plan model, same settings/identity/slots story, but built for event-driven, per-execution workloads (HTTP triggers, queue/timer/blob triggers) and able to run on a Consumption plan where you pay per execution and scale to zero. It needs a storage account for its runtime bookkeeping (triggers, leases, logs), which is the one extra moving part versus a Web App.

resource "azurerm_service_plan" "func" {
  name                = "plan-kvn-func"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  os_type             = "Linux"
  sku_name            = "Y1"   # Consumption (serverless, pay-per-exec, scale-to-zero)
}

resource "azurerm_linux_function_app" "func" {
  name                = "func-kvn-prod"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  service_plan_id     = azurerm_service_plan.func.id

  storage_account_name       = azurerm_storage_account.func.name
  storage_uses_managed_identity = true   # keyless — no storage key in config

  site_config {
    application_stack { node_version = "20" }
  }

  identity { type = "SystemAssigned" }
}

Function App hosting plans, which is the decision that defines cost and cold-start behaviour:

Plan sku_name Scaling Cold start Cost model Use for
Consumption Y1 Auto, to zero Yes (idle) Per-execution + GB-s Spiky/low-volume events
Flex Consumption (dedicated resource) Fast, to zero, per-instance concurrency Reduced Per-execution, always-ready option Modern default for serverless
Elastic Premium EP1EP3 Auto, pre-warmed No (min instances) Per-instance-hour VNet + no cold start
Dedicated S1/P1v3 Manual/autoscale No (Always On) Per-plan Reuse an existing plan

Note storage_uses_managed_identity = true — the modern, keyless way to wire the runtime storage account, so no storage access key ends up in config or state. Everything else you learned — app settings, Key Vault references, slots, custom domains, VNet integration — applies to Function Apps unchanged.

Hands-on: build it with Terraform

Now the centrepiece. We will stand up the full stack — plan, Key Vault, Web App with a Key Vault reference and managed identity, and a staging slot — apply it, curl the live app to prove it serves, then destroy it. The custom-domain resources are included but gated behind a variable, because they need a real DNS zone you own; the core demo runs without one. ⚠️ This provisions real, billable Azure resources (an S1 plan is roughly $70/month prorated hourly, so a one-hour lab is cents) — do the destroy step at the end.

Prerequisites: Terraform ≥ 1.6 (or OpenTofu ≥ 1.6 — the azurerm config is identical), the Azure CLI logged in (az login), a subscription you can create resources in, and a remote-state storage account (create once with az storage account create / az storage container create, or use a local backend for the lab).

Step 1 — versions.tf: pin the provider and backend

# versions.tf
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }

  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstatekvn01"   # globally unique, yours
    container_name       = "tfstate"
    key                  = "appservice/demo.tfstate"
  }
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id   # azurerm 4.x REQUIRES this (or ARM_SUBSCRIPTION_ID)
}

Two azurerm 4.x facts: features {} is mandatory even when empty, and the provider now requires subscription_id (via the argument or the ARM_SUBSCRIPTION_ID environment variable) — a change that trips people upgrading from 3.x.

Step 2 — variables.tf: parameterise it

# variables.tf
variable "subscription_id" {
  type        = string
  description = "Azure subscription ID to deploy into."
}

variable "location" {
  type    = string
  default = "eastus"
}

variable "prefix" {
  type    = string
  default = "kvn"
}

variable "sku_name" {
  type        = string
  default     = "S1" # Standard — needed for the staging slot
  description = "App Service plan SKU. Must be S1+ for deployment slots."
}

variable "container_image" {
  type    = string
  default = "azuredocs/aci-helloworld:latest"
}

variable "custom_domain" {
  type        = string
  default     = "" # e.g. "www.kloudvin.com" — leave empty to skip domain+TLS
  description = "Set to bind a custom domain + managed TLS cert. Requires a DNS zone you own."
}

Step 3 — main.tf: the whole stack

# main.tf
data "azurerm_client_config" "current" {}

locals {
  suffix = substr(md5(var.subscription_id), 0, 6) # cheap uniqueness for global names
  tags   = { course = "terraform-zero-to-hero", lesson = "app-service", env = "demo" }
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${var.prefix}-appsvc-demo"
  location = var.location
  tags     = local.tags
}

# --- Key Vault (RBAC mode) + a secret + grant the DEPLOYER rights to write it ---
resource "azurerm_key_vault" "this" {
  name                      = "kv-${var.prefix}-${local.suffix}"
  location                  = azurerm_resource_group.this.location
  resource_group_name       = azurerm_resource_group.this.name
  tenant_id                 = data.azurerm_client_config.current.tenant_id
  sku_name                  = "standard"
  enable_rbac_authorization = true # use RBAC, not access policies
  tags                      = local.tags
}

resource "azurerm_role_assignment" "deployer_kv" {
  scope                = azurerm_key_vault.this.id
  role_definition_name = "Key Vault Secrets Officer"
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "azurerm_key_vault_secret" "db_password" {
  name         = "db-password"
  value        = "S3cr3t-do-not-commit-${local.suffix}" # demo only; real secret comes from elsewhere
  key_vault_id = azurerm_key_vault.this.id
  depends_on   = [azurerm_role_assignment.deployer_kv] # wait for RBAC to propagate
}

# --- App Service plan (Standard, Linux) ---
resource "azurerm_service_plan" "web" {
  name                = "plan-${var.prefix}-web"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  os_type             = "Linux"
  sku_name            = var.sku_name
  worker_count        = 1
  tags                = local.tags
}

# --- Linux Web App: container + settings + KV reference + identity ---
resource "azurerm_linux_web_app" "app" {
  name                = "app-${var.prefix}-web-${local.suffix}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_service_plan.web.location
  service_plan_id     = azurerm_service_plan.web.id
  https_only          = true
  tags                = local.tags

  site_config {
    always_on           = true
    http2_enabled       = true
    minimum_tls_version = "1.2"
    ftps_state          = "Disabled"
    health_check_path   = "/"
    application_stack {
      docker_registry_url = "https://mcr.microsoft.com"
      docker_image_name   = var.container_image
    }
  }

  app_settings = {
    "WEBSITES_PORT" = "80"
    "APP_ENV"       = "production"
    "DB_PASSWORD"   = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.db_password.versionless_id})"
  }

  sticky_settings {
    app_setting_names = ["APP_ENV"] # stays put on a slot swap
  }

  identity { type = "SystemAssigned" }
}

# Grant the app's identity read access to Key Vault secrets (for the KV reference)
resource "azurerm_role_assignment" "app_kv" {
  scope                = azurerm_key_vault.this.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_linux_web_app.app.identity[0].principal_id
}

# --- Staging slot (blue-green) ---
resource "azurerm_linux_web_app_slot" "staging" {
  name           = "staging"
  app_service_id = azurerm_linux_web_app.app.id
  https_only     = true

  site_config {
    always_on         = true
    health_check_path = "/"
    application_stack {
      docker_registry_url = "https://mcr.microsoft.com"
      docker_image_name   = var.container_image
    }
  }

  app_settings = {
    "WEBSITES_PORT" = "80"
    "APP_ENV"       = "staging"
  }

  identity { type = "SystemAssigned" }
}

# The SLOT has its own identity — grant it separately, or KV refs fail after a swap
resource "azurerm_role_assignment" "slot_kv" {
  scope                = azurerm_key_vault.this.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_linux_web_app_slot.staging.identity[0].principal_id
}

# --- Custom domain + free managed TLS (only if var.custom_domain is set) ---
resource "azurerm_app_service_custom_hostname_binding" "custom" {
  count               = var.custom_domain == "" ? 0 : 1
  hostname            = var.custom_domain
  app_service_name    = azurerm_linux_web_app.app.name
  resource_group_name = azurerm_resource_group.this.name
  lifecycle { ignore_changes = [ssl_state, thumbprint] }
}

resource "azurerm_app_service_managed_certificate" "custom" {
  count                      = var.custom_domain == "" ? 0 : 1
  custom_hostname_binding_id = azurerm_app_service_custom_hostname_binding.custom[0].id
}

resource "azurerm_app_service_certificate_binding" "custom" {
  count               = var.custom_domain == "" ? 0 : 1
  hostname_binding_id = azurerm_app_service_custom_hostname_binding.custom[0].id
  certificate_id      = azurerm_app_service_managed_certificate.custom[0].id
  ssl_state           = "SniEnabled"
}

Step 4 — outputs.tf: what you need to verify

# outputs.tf
output "default_hostname" {
  value = "https://${azurerm_linux_web_app.app.default_hostname}"
}

output "staging_hostname" {
  value = "https://${azurerm_linux_web_app_slot.staging.default_hostname}"
}

output "app_principal_id" {
  value = azurerm_linux_web_app.app.identity[0].principal_id
}

output "domain_verification_id" {
  description = "Put this in an asuid TXT record to verify a custom domain."
  value       = azurerm_linux_web_app.app.custom_domain_verification_id
}

Step 5 — init, plan, apply

export ARM_SUBSCRIPTION_ID="<your-sub-id>"   # or pass -var subscription_id=...
terraform init
Initializing the backend...
Initializing provider plugins...
- Installing hashicorp/azurerm v4.x.x...
Terraform has been successfully initialized!
terraform plan -var "subscription_id=$ARM_SUBSCRIPTION_ID"
Terraform will perform the following actions:
  # azurerm_resource_group.this           will be created
  # azurerm_key_vault.this                will be created
  # azurerm_key_vault_secret.db_password  will be created
  # azurerm_service_plan.web              will be created
  # azurerm_linux_web_app.app             will be created
  # azurerm_linux_web_app_slot.staging    will be created
  # azurerm_role_assignment.app_kv        will be created
  # azurerm_role_assignment.slot_kv       will be created
  # azurerm_role_assignment.deployer_kv   will be created

Plan: 9 to add, 0 to change, 0 to destroy.
Changes to Outputs:
  + default_hostname = (known after apply)
terraform apply -var "subscription_id=$ARM_SUBSCRIPTION_ID" -auto-approve

Apply takes a few minutes (the Web App and its slot pull the container image and start). On success:

Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:
default_hostname = "https://app-kvn-web-3f9a1c.azurewebsites.net"
staging_hostname = "https://app-kvn-web-3f9a1c-staging.azurewebsites.net"

Step 6 — Verify it actually serves

# The live app should return HTTP 200 and the sample container's HTML:
curl -sSI "$(terraform output -raw default_hostname)"
HTTP/2 200
content-type: text/html; charset=utf-8
...

Confirm the Key Vault reference resolved (this is the moment of truth for managed identity). In the portal, the app’s Environment variables blade shows DB_PASSWORD with a green “Key Vault Reference” source; via CLI:

az webapp config appsettings list \
  --name "$(terraform output -raw default_hostname | sed 's#https://##;s#\.azurewebsites\.net##')" \
  --resource-group rg-kvn-appsvc-demo \
  --query "[?name=='DB_PASSWORD'].{name:name, value:value}" -o table

If the value shows the literal @Microsoft.KeyVault(...) string, the reference failed to resolve — jump to troubleshooting (almost always a missing role assignment or RBAC not yet propagated; a restart fixes the propagation case). Finally, hit the staging slot on its own URL to prove blue-green is wired:

curl -sSI "$(terraform output -raw staging_hostname)"   # HTTP/2 200 from staging

To perform the actual swap (imperative, not Terraform):

az webapp deployment slot swap --name <app-name> \
  --resource-group rg-kvn-appsvc-demo --slot staging --target-slot production

Step 7 — ⚠️ Destroy and clean up

terraform destroy -var "subscription_id=$ARM_SUBSCRIPTION_ID" -auto-approve
Destroy complete! Resources: 9 destroyed.

One cleanup nuance: an RBAC-mode Key Vault with soft-delete + purge protection (the default for standard) leaves a soft-deleted vault behind after destroy for the retention window; the name is reserved until it purges or you run az keyvault purge --name kv-.... If you re-apply with the same name before it purges, you’ll get a name-conflict — either purge it or vary the name.

Variables, outputs & making it reusable

The demo is a flat root module — perfect for learning, wrong for scale. Two moves make it reusable. First, wrap it in a module with a clean input surface (name, plan SKU, container image, app settings, a create_slot toggle, an optional custom domain) and outputs (hostname, principal ID). Second, when you need many apps on one plan, drive them with for_each over a map so adding an app is a one-line change:

variable "apps" {
  type = map(object({
    image         = string
    always_on     = optional(bool, true)
    create_slot   = optional(bool, false)
    app_settings  = optional(map(string), {})
  }))
}

resource "azurerm_linux_web_app" "app" {
  for_each            = var.apps
  name                = "app-${var.prefix}-${each.key}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_service_plan.web.location
  service_plan_id     = azurerm_service_plan.web.id  # all apps SHARE one plan → shared cost
  site_config {
    always_on = each.value.always_on
    application_stack {
      docker_registry_url = "https://mcr.microsoft.com"
      docker_image_name   = each.value.image
    }
  }
  app_settings = each.value.app_settings
  identity { type = "SystemAssigned" }
}

That is the pattern that makes App Service cheap: one plan, many apps via for_each, each app a map entry. Should you use a community module instead of rolling your own? The trade-off:

Option Example When to use
Roll your own module the code above You want full control, a thin surface, few surprises
Azure Verified Module Azure/avm-res-web-site/azurerm You want Microsoft-maintained defaults, WAF-aligned patterns
Community module claranet/app-service-linux/azurerm Batteries-included (diag settings, slots, insights)

Azure Verified Modules (AVM) are the current Microsoft-blessed registry modules and a sensible default for teams that want opinionated, tested building blocks; roll your own when the module’s abstraction fights your needs more than it helps. Either way, keep the plan and the apps as separate module instances so you can scale apps without touching plan state.

Autoscale

Manual worker_count is a fixed bill. Autoscale (azurerm_monitor_autoscale_setting) targets the plan and adds/removes instances on metrics — CPU, memory, or a schedule. It needs Standard+ (Basic caps at 3 manual instances, no autoscale).

resource "azurerm_monitor_autoscale_setting" "web" {
  name                = "autoscale-${var.prefix}-web"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  target_resource_id  = azurerm_service_plan.web.id

  profile {
    name = "cpu-based"
    capacity {
      minimum = 2
      maximum = 10
      default = 2
    }

    rule {
      metric_trigger {
        metric_name        = "CpuPercentage"
        metric_resource_id = azurerm_service_plan.web.id
        time_grain         = "PT1M"
        statistic          = "Average"
        time_window        = "PT5M"
        time_aggregation   = "Average"
        operator           = "GreaterThan"
        threshold          = 70
      }
      scale_action {
        direction = "Increase"
        type      = "ChangeCount"
        value     = "1"
        cooldown  = "PT5M"
      }
    }
    # (mirror rule: LessThan 30 → Decrease by 1)
  }
}

The autoscale knobs and their sane starting values:

Setting What it does Sensible value
capacity.minimum Never scale below this 2 (so one restart isn’t an outage)
capacity.maximum Hard ceiling (cost guardrail) 10 (or your SKU’s limit)
metric_trigger.threshold Scale-out trigger CPU > 70% over 5 min
scale_action.cooldown Wait between scale actions 5–10 min (avoid flapping)
Scale-in threshold Scale-back trigger CPU < 30% (asymmetric — scale out fast, in slow)

Always pair a scale-out rule with a scale-in rule (omitted above for brevity) or you ratchet up and never come down. And set minimum ≥ 2 so a single instance restart never zeroes capacity.

Common mistakes and troubleshooting

App Service has a signature set of failure modes, and almost all of them are configuration, not code. Scan the table, then read the prose on the four nastiest:

Symptom Likely cause Fix
Container returns 502/503, logs show “didn’t respond to HTTP pings on port: 80” App listens on a port ≠ 80, WEBSITES_PORT unset Set app_settings["WEBSITES_PORT"] to the real port; bind 0.0.0.0
App setting shows literal @Microsoft.KeyVault(...) Identity missing or not granted vault access Add identity {} + Key Vault Secrets User role; wait for RBAC; restart
KV reference works in prod, breaks after a slot swap Slot has its own identity, ungranted Grant azurerm_linux_web_app_slot.<x>.identity[0].principal_id separately
Slow first request after idle (~20 min) always_on = false (or Free/Shared tier) Set always_on = true; needs B1+
always_on apply errors on F1 Feature not on Free/Shared Move plan to B1+; then enable
terraform apply fails creating the slot SKU has no slots (Basic/Free) Use sku_name = "S1" or higher
Managed cert never issues / apply hangs on cert Domain not verified / DNS not propagated Add asuid TXT + CNAME/A first; depends_on DNS; wait for propagation
Custom hostname binding “conflict”/drift every plan Cert binding sets ssl_state/thumbprint out-of-band lifecycle { ignore_changes = [ssl_state, thumbprint] }
azurerm v4 provider error: subscription not specified v4 requires subscription_id Set provider subscription_id or ARM_SUBSCRIPTION_ID
KV secret create fails “Forbidden” Deployer lacks vault data-plane rights (RBAC mode) Grant Key Vault Secrets Officer to the deployer; depends_on it
App works, sticky setting leaked to staging Setting not in sticky_settings Add its name to sticky_settings.app_setting_names
Whole app returns 503 when DB blips health_check_path hard-fails on a downstream Make the health path shallow; don’t fail liveness on optional deps

Cold start & always_on. The single most common “the app is slow sometimes” report is cold start: with always_on = false, App Service unloads an idle worker after ~20 minutes, and the next request pays full process + container-pull + JIT startup. Set always_on = true (B1 and up) and the platform keeps a warm worker resident. On Free/Shared, always_on isn’t available at all — which is one more reason those tiers are for experiments only. Setting always_on = true on an F1 plan will error at apply.

Slot settings. The blue-green swap carries all non-sticky settings across. The two failure shapes: (1) a setting that must stay with its environment (a slot-specific feature flag) travels on the swap because you forgot sticky_settings — now production has staging’s flag; (2) the swapped-in slot has its own managed identity, ungranted, so every Key Vault reference that worked in production fails the instant staging becomes production. Grant the slot identity, and pin environment-bound settings sticky. These two together cause the majority of “it worked until we swapped” incidents.

Key Vault reference denied. When DB_PASSWORD shows as the literal @Microsoft.KeyVault(...) token, the resolution failed. Walk the chain: is there an identity {} block (does the app have a principal)? Is that principal granted Key Vault Secrets User on this vault (RBAC mode) or get on secrets (access-policy mode)? Has the RBAC assignment propagated (30–120 s)? Is the vault’s network firewall blocking the app? The order Terraform applies is correct, but runtime resolution can lag the role assignment — a restart after propagation is the usual cure, which is why some teams add a settings-hash trigger to force a post-grant restart.

Domain verification. A managed certificate can only issue for a verified hostname, and verification is pure DNS: the asuid.<sub> TXT record must contain the app’s custom_domain_verification_id, and the CNAME/A must resolve to the app before the binding and cert resources apply. Racing them — creating the binding before DNS propagates — makes apply fail or hang. Order with depends_on on the DNS records and accept that DNS propagation is out of Terraform’s control (a few minutes). If you don’t own the zone in Azure DNS, create those records at your registrar first, then apply.

Cost, cleanup & production notes

What it costs if you leave it running. The dominant line item is the plan, billed hourly whether or not the app gets traffic — the apps and slots on it are free-of-additional-charge tenants. The demo’s other resources are negligible:

Resource Demo cost driver ~Monthly (left running) Notes
App Service plan (S1) The compute you rent ~$70 Billed hourly; the big number
Web App + staging slot Runs on the plan $0 extra Slots share the plan’s capacity
Key Vault (standard) Per-operation < $1 Secrets ops are cents
Managed TLS certificate $0 Free, auto-renewing
DNS zone (if used) Per zone + queries ~$0.50 Only if you host DNS in Azure

A one-hour lab on S1 is a few cents. The trap is leaving the plan up — an idle S1 still bills ~$70/month. terraform destroy removes everything; remember the soft-deleted Key Vault caveat (purge it if you’ll re-use the name).

Production hardening. Five notes that separate a demo from production:

Area Do this Why
State Remote azurerm backend, blob lease locking, one state per env No local terraform.tfstate; locking prevents concurrent-apply corruption
Secrets Only Key Vault references; never a raw secret in app_settings Raw values land in state as plaintext
Identity System-assigned identity + least-privilege RBAC; no admin keys, no ACR admin user Keyless, auditable, rotatable-free
Tags & naming Consistent tags, a naming convention, default_tags-style locals Cost allocation, ownership, cleanup
Drift Scheduled terraform plan in CI; alert on non-empty diff Someone will click in the portal — catch it

Beyond those: run ≥2 instances in prod so a restart isn’t an outage; deploy via slot-swap (not in-place) for near-zero-downtime; keep the container image small and same-region (a big image is a slow cold start); and put ignore_changes on the exact attributes the platform mutates out-of-band (cert bindings, some app settings written by extensions) so Terraform doesn’t fight the platform on every plan.

Cheat-sheet

The resources, at a glance:

Resource Purpose
azurerm_service_plan The compute (OS + SKU + workers) apps share
azurerm_linux_web_app / azurerm_windows_web_app The web app itself
azurerm_linux_web_app_slot A blue-green staging slot
azurerm_linux_function_app Serverless sibling
azurerm_app_service_custom_hostname_binding Bind a custom domain
azurerm_app_service_managed_certificate Free auto-renewing TLS cert
azurerm_app_service_certificate_binding Attach the cert (SNI)
azurerm_role_assignment Grant the app’s identity RBAC access
azurerm_monitor_autoscale_setting Metric/schedule-based scaling

The arguments and settings you’ll reach for most:

Need Where Value
No cold starts site_config.always_on true (B1+)
Container port app_settings.WEBSITES_PORT your port (e.g. "8080")
Force HTTPS resource https_only true
Health probe site_config.health_check_path "/healthz"
Keyless identity identity { type = "SystemAssigned" }
Secret without plaintext app_settings value @Microsoft.KeyVault(SecretUri=...)
Pin setting across swap sticky_settings.app_setting_names ["APP_ENV"]
Slots require plan sku_name S1+
Verify domain custom_domain_verification_id into asuid TXT

Commands:

terraform init && terraform plan && terraform apply
az webapp deployment slot swap -n <app> -g <rg> --slot staging --target-slot production
az webapp log tail -n <app> -g <rg>                 # live logs (debug 502/cold start)
az keyvault purge --name <kv-name>                  # after destroy, free the name
terraform destroy

Interview and exam questions

1. Why does putting many apps on one App Service plan save money? You pay for the plan (its SKU × instances), not per app. Every Web App and Function App on a plan shares its CPU/RAM, so ten small apps on one S1 cost one S1 — until they contend for capacity, at which point you split them or scale up.

2. Your container returns 502 and logs say “didn’t respond to HTTP pings on port: 80.” What’s wrong? The container listens on a port other than 80 and WEBSITES_PORT is unset, so the platform probes 80 and gets connection-refused. Set app_settings["WEBSITES_PORT"] to the real port and ensure the app binds 0.0.0.0, not 127.0.0.1.

3. What is a Key Vault reference and what must be true for it to resolve? An app setting whose value is @Microsoft.KeyVault(...), resolved at runtime via the app’s managed identity — so the plaintext never enters config or state. It needs (a) a managed identity on the app and (b) that identity granted read on the vault (RBAC Key Vault Secrets User or an access policy get), with RBAC propagated.

4. You swap staging into production and Key Vault references start failing. Why? A slot has its own managed identity, distinct from production’s. If you only granted the production identity vault access, the swapped-in slot’s identity was never granted — grant the slot’s principal_id separately.

5. Why won’t a staging slot deploy on a B1 plan? Basic supports zero deployment slots. Slots require Standard (5 slots) or higher. Use sku_name = "S1" or above.

6. sticky_settings — what problem does it solve? By default a slot swap carries all app settings/connection strings with the app. sticky_settings pins named settings to their slot so environment-bound values (a slot-specific flag, an env name) don’t travel on the swap.

7. Terraform Associate style — how do you keep a secret out of state? Don’t put the plaintext in config at all: use a Key Vault reference in app_settings, so state stores only the reference token. (For values Terraform must read, mark outputs sensitive, but the reference approach keeps the secret entirely out of state.)

8. Why is the slot swap not a Terraform resource? Terraform models desired state; a swap is an imperative, point-in-time operation that flips which workers serve production. You trigger it with az webapp deployment slot swap from a pipeline, keeping Terraform for the declarative topology.

9. What changed about the subscription_id in azurerm v4? It’s now required — set the provider subscription_id argument or ARM_SUBSCRIPTION_ID. In v3 it could be inferred from the CLI context; v4 makes it explicit.

10. Managed certificate vs Key Vault certificate — when each? The free App Service managed certificate for standard public single-hostname HTTPS (auto-renews, zero cost). A Key Vault certificate when you need a wildcard (managed certs don’t cover *.domain), an EV cert, or centralised certificate governance.

11. Terraform Associate style — an app setting shows drift on every plan. How do you stop the noise without abandoning management? Identify the attribute the platform mutates out-of-band (e.g. a cert binding’s ssl_state/thumbprint, or a setting an extension writes) and add lifecycle { ignore_changes = [...] } for exactly those attributes — not the whole resource.

12. How do you eliminate cold starts, and what’s the one tier caveat? Set always_on = true so a warm worker stays resident. Caveat: it needs Basic (B1) or higher — it’s unavailable on Free/Shared, and applying it there errors.

Key takeaways

TerraformazurermApp ServiceWeb AppsDeployment SlotsManaged IdentityKey VaultCustom DomainsFunction AppsPaaSTLSIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments