IaC Azure

Terraform Module: Azure App Service (Web App) — ship a hardened Linux web app in one block

Quick take — A production-ready Terraform module for Azure Linux Web App on azurerm ~> 4.0: HTTPS-only, system-assigned identity, health checks, app settings, slots and VNet integration baked in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "app_service" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-service?ref=v1.0.0"

  name                = "..."  # Globally unique web app name; becomes `<name>.azurewebs…
  resource_group_name = "..."  # Resource group for the app and plan.
  location            = "..."  # Azure region, e.g. `centralindia`.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure App Service (Web App) is the platform-as-a-service way to run web applications and APIs on Azure without managing the underlying VMs, OS patching, or load balancers. On Linux it runs your code from a built-in runtime stack (Node, .NET, Python, Java, PHP, Go) or straight from a container image, and you pay for an App Service Plan that defines the compute SKU shared across one or more apps.

The raw azurerm_linux_web_app resource is deceptively large: it has nested site_config, application_stack, auth_settings_v2, logs, sticky_settings, and identity blocks, and getting production defaults right (HTTPS-only, minimum TLS 1.2, FTPS disabled, health checks, always_on) is easy to forget and easy to drift on. Wrapping it in a reusable module means every web app your team deploys is secure-by-default, consistently named, wired to a managed identity, and emits the same outputs (hostname, identity principal ID) that downstream Key Vault access policies and DNS records depend on. You write five variables; the module enforces the other thirty decisions.

When to use it

Module structure

terraform-module-azure-app-service/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_linux_web_app + optional plan, slot, vnet integration
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # id, name, hostname, identity principal id, plan id

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Merge caller settings with a couple of safe defaults.
  app_settings = merge(
    {
      "WEBSITE_HTTPLOGGING_RETENTION_DAYS" = "7"
    },
    var.app_settings
  )

  tags = merge(
    {
      managed_by = "terraform"
      module     = "terraform-module-azure-app-service"
    },
    var.tags
  )
}

# Optionally create the App Service Plan, or reuse an existing one by ID.
resource "azurerm_service_plan" "this" {
  count = var.create_service_plan ? 1 : 0

  name                = coalesce(var.service_plan_name, "asp-${var.name}")
  resource_group_name = var.resource_group_name
  location            = var.location
  os_type             = "Linux"
  sku_name            = var.sku_name

  # Zone balancing requires a Premium v2/v3 SKU and >= 2 workers.
  zone_balancing_enabled = var.zone_balancing_enabled
  worker_count           = var.worker_count

  tags = local.tags
}

resource "azurerm_linux_web_app" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  service_plan_id = var.create_service_plan ? azurerm_service_plan.this[0].id : var.service_plan_id

  https_only                    = true
  public_network_access_enabled = var.public_network_access_enabled
  virtual_network_subnet_id     = var.vnet_integration_subnet_id

  site_config {
    always_on                         = var.always_on
    http2_enabled                     = true
    ftps_state                        = "Disabled"
    minimum_tls_version               = "1.2"
    health_check_path                 = var.health_check_path
    health_check_eviction_time_in_min = var.health_check_path == null ? null : 5
    worker_count                      = var.worker_count
    vnet_route_all_enabled            = var.vnet_integration_subnet_id != null

    application_stack {
      # Exactly one of these should be set by the caller.
      node_version        = var.runtime_stack == "node" ? var.runtime_version : null
      python_version      = var.runtime_stack == "python" ? var.runtime_version : null
      dotnet_version      = var.runtime_stack == "dotnet" ? var.runtime_version : null
      java_version        = var.runtime_stack == "java" ? var.runtime_version : null
      docker_image_name   = var.runtime_stack == "docker" ? var.docker_image_name : null
      docker_registry_url = var.runtime_stack == "docker" ? var.docker_registry_url : null
    }

    dynamic "ip_restriction" {
      for_each = var.allowed_ip_cidrs
      content {
        name       = "allow-${ip_restriction.key}"
        action     = "Allow"
        priority   = 100 + ip_restriction.key
        ip_address = ip_restriction.value
      }
    }
  }

  identity {
    type = "SystemAssigned"
  }

  app_settings = local.app_settings

  logs {
    http_logs {
      file_system {
        retention_in_days = 7
        retention_in_mb   = 35
      }
    }
    application_logs {
      file_system_level = "Information"
    }
  }

  lifecycle {
    # App settings injected by deployment slots / CI should not cause drift.
    ignore_changes = [
      app_settings["WEBSITE_RUN_FROM_PACKAGE"],
    ]
  }

  tags = local.tags
}

# Optional staging slot for blue/green deployments (Standard SKU and up).
resource "azurerm_linux_web_app_slot" "staging" {
  count = var.enable_staging_slot ? 1 : 0

  name           = "staging"
  app_service_id = azurerm_linux_web_app.this.id

  https_only                = true
  virtual_network_subnet_id = var.vnet_integration_subnet_id

  site_config {
    always_on           = var.always_on
    minimum_tls_version = "1.2"
    ftps_state          = "Disabled"
    health_check_path   = var.health_check_path

    application_stack {
      node_version        = var.runtime_stack == "node" ? var.runtime_version : null
      python_version      = var.runtime_stack == "python" ? var.runtime_version : null
      dotnet_version      = var.runtime_stack == "dotnet" ? var.runtime_version : null
      java_version        = var.runtime_stack == "java" ? var.runtime_version : null
      docker_image_name   = var.runtime_stack == "docker" ? var.docker_image_name : null
      docker_registry_url = var.runtime_stack == "docker" ? var.docker_registry_url : null
    }
  }

  identity {
    type = "SystemAssigned"
  }

  app_settings = local.app_settings

  tags = local.tags
}

variables.tf

variable "name" {
  type        = string
  description = "Globally unique name of the web app (becomes <name>.azurewebsites.net)."

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$", var.name))
    error_message = "Name must be 2-60 chars, lowercase alphanumeric or hyphen, not starting/ending with a hyphen."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group to deploy the web app (and plan) into."
}

variable "location" {
  type        = string
  description = "Azure region, e.g. 'centralindia' or 'eastus'."
}

variable "create_service_plan" {
  type        = bool
  description = "Create a dedicated App Service Plan. Set false to reuse one via service_plan_id."
  default     = true
}

variable "service_plan_id" {
  type        = string
  description = "Existing Linux App Service Plan ID to reuse (required when create_service_plan = false)."
  default     = null
}

variable "service_plan_name" {
  type        = string
  description = "Name for the created plan. Defaults to 'asp-<name>'."
  default     = null
}

variable "sku_name" {
  type        = string
  description = "App Service Plan SKU. Use B1/B2 for dev, P1v3+ for production (zone redundancy needs Pv3)."
  default     = "P1v3"

  validation {
    condition = contains(
      ["B1", "B2", "B3", "S1", "S2", "S3", "P0v3", "P1v3", "P2v3", "P3v3"],
      var.sku_name
    )
    error_message = "sku_name must be one of B1-B3, S1-S3, or P0v3-P3v3."
  }
}

variable "runtime_stack" {
  type        = string
  description = "Runtime stack: node, python, dotnet, java, or docker."
  default     = "node"

  validation {
    condition     = contains(["node", "python", "dotnet", "java", "docker"], var.runtime_stack)
    error_message = "runtime_stack must be node, python, dotnet, java, or docker."
  }
}

variable "runtime_version" {
  type        = string
  description = "Runtime version string, e.g. '20-lts' for node, '3.12' for python, '8.0' for dotnet. Ignored for docker."
  default     = "20-lts"
}

variable "docker_image_name" {
  type        = string
  description = "Container image and tag when runtime_stack = docker, e.g. 'myapp:1.4.2'."
  default     = null
}

variable "docker_registry_url" {
  type        = string
  description = "Container registry URL when runtime_stack = docker, e.g. 'https://myacr.azurecr.io'."
  default     = null
}

variable "always_on" {
  type        = bool
  description = "Keep the app warm. Must be false on Free/Basic-shared tiers; true is recommended for production."
  default     = true
}

variable "health_check_path" {
  type        = string
  description = "Path App Service pings to detect unhealthy instances, e.g. '/healthz'. Null disables it."
  default     = null
}

variable "worker_count" {
  type        = number
  description = "Number of instances (workers) for the plan / app scale-out."
  default     = 1

  validation {
    condition     = var.worker_count >= 1 && var.worker_count <= 30
    error_message = "worker_count must be between 1 and 30."
  }
}

variable "zone_balancing_enabled" {
  type        = bool
  description = "Spread instances across availability zones. Requires Pv3 SKU and worker_count >= 2."
  default     = false
}

variable "public_network_access_enabled" {
  type        = bool
  description = "Allow public internet access to the app. Set false when fronting with Private Endpoint / App Gateway."
  default     = true
}

variable "vnet_integration_subnet_id" {
  type        = string
  description = "Delegated subnet ID for regional VNet integration (outbound). Null disables integration."
  default     = null
}

variable "allowed_ip_cidrs" {
  type        = list(string)
  description = "Inbound IP allow-list (CIDRs). Empty list leaves access open (subject to public_network_access)."
  default     = []
}

variable "enable_staging_slot" {
  type        = bool
  description = "Create a 'staging' deployment slot for blue/green swaps. Requires Standard SKU or higher."
  default     = false
}

variable "app_settings" {
  type        = map(string)
  description = "Application settings (environment variables) merged into the app."
  default     = {}
}

variable "tags" {
  type        = map(string)
  description = "Tags merged onto the plan, app, and slot."
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Linux Web App."
  value       = azurerm_linux_web_app.this.id
}

output "name" {
  description = "Name of the Linux Web App."
  value       = azurerm_linux_web_app.this.name
}

output "default_hostname" {
  description = "Default hostname (e.g. myapp.azurewebsites.net) without scheme."
  value       = azurerm_linux_web_app.this.default_hostname
}

output "default_url" {
  description = "Full HTTPS URL of the web app."
  value       = "https://${azurerm_linux_web_app.this.default_hostname}"
}

output "identity_principal_id" {
  description = "Principal (object) ID of the system-assigned managed identity, for Key Vault / RBAC grants."
  value       = azurerm_linux_web_app.this.identity[0].principal_id
}

output "outbound_ip_addresses" {
  description = "Comma-separated list of possible outbound IPs (for firewall allow-lists on databases)."
  value       = azurerm_linux_web_app.this.outbound_ip_addresses
}

output "service_plan_id" {
  description = "ID of the App Service Plan in use (created or reused)."
  value       = var.create_service_plan ? azurerm_service_plan.this[0].id : var.service_plan_id
}

output "staging_slot_hostname" {
  description = "Default hostname of the staging slot, or null when disabled."
  value       = var.enable_staging_slot ? azurerm_linux_web_app_slot.staging[0].default_hostname : null
}

How to use it

resource "azurerm_resource_group" "app" {
  name     = "rg-kloudvin-prod"
  location = "centralindia"
}

module "app_service_web_app_api" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-service?ref=v1.0.0"

  name                = "kloudvin-api-prod"
  resource_group_name = azurerm_resource_group.app.name
  location            = azurerm_resource_group.app.location

  sku_name        = "P1v3"
  runtime_stack   = "node"
  runtime_version = "20-lts"

  always_on              = true
  health_check_path      = "/healthz"
  worker_count           = 2
  zone_balancing_enabled = true
  enable_staging_slot    = true

  # Outbound integration into the spoke VNet so it can reach private SQL.
  vnet_integration_subnet_id    = azurerm_subnet.app_integration.id
  public_network_access_enabled = true

  app_settings = {
    "NODE_ENV"                              = "production"
    "KEY_VAULT_URI"                         = azurerm_key_vault.app.vault_uri
    "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.app.connection_string
  }

  tags = {
    environment = "prod"
    cost_center = "platform"
  }
}

# Downstream: grant the app's managed identity read access to Key Vault secrets,
# using the identity principal ID output by the module.
resource "azurerm_role_assignment" "app_kv_secrets" {
  scope                = azurerm_key_vault.app.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = module.app_service_web_app_api.identity_principal_id
}

# Downstream: point a custom DNS CNAME at the app's default hostname.
resource "azurerm_dns_cname_record" "api" {
  name                = "api"
  zone_name           = "kloudvin.com"
  resource_group_name = "rg-dns"
  ttl                 = 300
  record              = module.app_service_web_app_api.default_hostname
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/app_service/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-service?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/app_service && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string yes Globally unique web app name; becomes <name>.azurewebsites.net.
resource_group_name string yes Resource group for the app and plan.
location string yes Azure region, e.g. centralindia.
create_service_plan bool true no Create a dedicated plan, or reuse one via service_plan_id.
service_plan_id string null no Existing Linux plan ID (required when create_service_plan = false).
service_plan_name string null no Name for the created plan; defaults to asp-<name>.
sku_name string "P1v3" no Plan SKU; B1–B3, S1–S3, or P0v3–P3v3.
runtime_stack string "node" no One of node, python, dotnet, java, docker.
runtime_version string "20-lts" no Runtime version (ignored for docker).
docker_image_name string null no Image and tag when runtime_stack = docker.
docker_registry_url string null no Registry URL when runtime_stack = docker.
always_on bool true no Keep the app warm; must be false on shared tiers.
health_check_path string null no Path for instance health probes; null disables.
worker_count number 1 no Instance count (1–30).
zone_balancing_enabled bool false no Spread instances across zones (needs Pv3 + ≥2 workers).
public_network_access_enabled bool true no Allow public access; set false behind Private Endpoint.
vnet_integration_subnet_id string null no Delegated subnet ID for outbound VNet integration.
allowed_ip_cidrs list(string) [] no Inbound IP allow-list (CIDRs).
enable_staging_slot bool false no Create a staging slot (needs Standard SKU+).
app_settings map(string) {} no Application settings / environment variables.
tags map(string) {} no Tags merged onto plan, app, and slot.

Outputs

Name Description
id Resource ID of the Linux Web App.
name Name of the Linux Web App.
default_hostname Default hostname without scheme (e.g. myapp.azurewebsites.net).
default_url Full HTTPS URL of the app.
identity_principal_id Principal ID of the system-assigned managed identity, for Key Vault / RBAC grants.
outbound_ip_addresses Comma-separated outbound IPs for database firewall allow-lists.
service_plan_id ID of the App Service Plan in use (created or reused).
staging_slot_hostname Hostname of the staging slot, or null when disabled.

Enterprise scenario

A retail platform team runs about forty internal line-of-business APIs and admin portals on Azure. They consume this module from their landing-zone pipeline so every app lands as P1v3 with zone balancing across two workers, HTTPS-only with TLS 1.2, a /healthz probe, and VNet integration into the shared services spoke. The module’s identity_principal_id output is fed straight into Key Vault Secrets User role assignments, so no API ever ships a database password in an app setting; combined with the staging slot they swap releases with zero downtime during business hours.

Best practices

TerraformAzureApp Service (Web App)ModuleIaC
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

Keep Reading