IaC Azure

Terraform Module: Azure App Service Plan — One Reusable Compute Tier for All Your Web Apps

Quick take — A production-ready Terraform module for azurerm_service_plan on azurerm ~> 4.0: pick OS and SKU, control zone redundancy and per-app scaling, and expose the plan id every Web App needs. 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_plan" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-service-plan?ref=v1.0.0"

  name                = "..."  # Plan name (1-40 chars, letters/numbers/hyphens). Conven…
  resource_group_name = "..."  # Resource group that contains the 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

An Azure App Service Plan defines the compute fleet that hosts your Web Apps, Function Apps (Elastic/Dedicated), and API Apps. It is the thing you actually pay for: the SKU (B1, P1v3, I1v2, …) fixes the vCPU/RAM per instance, the worker count fixes how many instances run, and the OS kind (Windows or Linux) is baked in for the life of the plan. Individual apps are nearly free metadata that schedule onto that fleet — the plan is where the money and the scaling decisions live.

In azurerm ~> 4.0 this is the azurerm_service_plan resource (the old azurerm_app_service_plan is gone in v4). It is small but easy to get subtly wrong: os_type and sku_name are immutable, so a SKU typo or an OS mismatch forces a full plan replacement that takes every app on it down. Zone redundancy (zone_balancing_enabled) can only be turned on at creation and only on Premium v3 / Isolated v2 with a worker count that is a multiple of the zone count. Per-app scaling and the “always at least N instances” floor each have their own gotchas.

Wrapping it in a module gives every team one vetted way to provision the tier — consistent naming, a SKU allow-list enforced by validation, sane zone-redundancy guardrails for production, and a clean id output that every downstream azurerm_linux_web_app / azurerm_windows_web_app consumes. Nobody hand-types "P1v2" into a root config again.

When to use it

Reach for the Consumption (Y1) Function plan or Flex Consumption resources instead if you only run serverless functions with no need for a dedicated, always-on fleet.

Module structure

terraform-module-azure-app-service-plan/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_service_plan resource
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, name, kind, sku, worker count

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

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

  os_type  = var.os_type
  sku_name = var.sku_name

  # Number of running instances. For Isolated v2 this is the App Service
  # Environment worker count; for Pv3 it's the zone-balanced base count.
  worker_count = var.worker_count

  # Independent scaling for each app on the plan (Standard+ only).
  per_site_scaling_enabled = var.per_site_scaling_enabled

  # Spread instances across availability zones. Immutable; create-time only.
  # Requires a Pv3/Iv2 SKU and worker_count as a multiple of the zone count (3).
  zone_balancing_enabled = var.zone_balancing_enabled

  # For Linux Premium plans, keep at least one warm instance ready (no cold start).
  maximum_elastic_worker_count = var.maximum_elastic_worker_count

  # Bind a Dedicated/Isolated plan to an existing App Service Environment v3.
  app_service_environment_id = var.app_service_environment_id

  tags = var.tags

  lifecycle {
    # os_type and sku_name are immutable in-place; a change replaces the plan
    # and recreates every hosted app. Surface that loudly via precondition.
    precondition {
      condition     = !(var.zone_balancing_enabled && var.worker_count % 3 != 0)
      error_message = "zone_balancing_enabled requires worker_count to be a multiple of 3 (the supported zone count)."
    }
  }
}

variables.tf

variable "name" {
  description = "Name of the App Service Plan (globally unique within the resource group; convention: asp-<workload>-<env>-<region>)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-]{1,40}$", var.name))
    error_message = "name must be 1-40 characters: letters, numbers, and hyphens only."
  }
}

variable "resource_group_name" {
  description = "Name of the resource group that will contain the plan."
  type        = string
}

variable "location" {
  description = "Azure region for the plan (e.g. centralindia, westeurope)."
  type        = string
}

variable "os_type" {
  description = "Operating system the plan hosts. Immutable after creation."
  type        = string
  default     = "Linux"

  validation {
    condition     = contains(["Linux", "Windows", "WindowsContainer"], var.os_type)
    error_message = "os_type must be one of: Linux, Windows, WindowsContainer."
  }
}

variable "sku_name" {
  description = "Plan SKU. Governs vCPU/RAM per instance and feature set. Immutable in-place (changing it replaces the plan)."
  type        = string
  default     = "P1v3"

  validation {
    # Governed allow-list: Basic, Standard, Premium v2/v3, Isolated v2.
    # Consumption (Y1) and the legacy Shared (F1/D1) tiers are intentionally excluded.
    condition = contains([
      "B1", "B2", "B3",
      "S1", "S2", "S3",
      "P1v2", "P2v2", "P3v2",
      "P0v3", "P1v3", "P2v3", "P3v3",
      "P1mv3", "P2mv3", "P3mv3", "P4mv3", "P5mv3",
      "I1v2", "I2v2", "I3v2", "I4v2", "I5v2", "I6v2"
    ], var.sku_name)
    error_message = "sku_name is not in the approved allow-list. Use a Basic, Standard, Premium v2/v3, or Isolated v2 SKU."
  }
}

variable "worker_count" {
  description = "Number of instances (workers) running the plan. Must be a multiple of 3 when zone_balancing_enabled is true."
  type        = number
  default     = 1

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

variable "per_site_scaling_enabled" {
  description = "Allow each app on the plan to scale independently of the others (Standard tier and above)."
  type        = bool
  default     = false
}

variable "zone_balancing_enabled" {
  description = "Spread instances across availability zones for HA. Create-time only; requires a Pv3/Iv2 SKU and worker_count divisible by 3."
  type        = bool
  default     = false
}

variable "maximum_elastic_worker_count" {
  description = "Maximum number of elastic workers for the plan. Only meaningful on Premium (elastic) plans; leave null otherwise."
  type        = number
  default     = null
}

variable "app_service_environment_id" {
  description = "Resource ID of an App Service Environment v3 to deploy an Isolated v2 plan into. Null for the multi-tenant (public) service."
  type        = string
  default     = null
}

variable "tags" {
  description = "Tags applied to the App Service Plan."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the App Service Plan. Pass this to service_plan_id on Web/Function Apps."
  value       = azurerm_service_plan.this.id
}

output "name" {
  description = "Name of the App Service Plan."
  value       = azurerm_service_plan.this.name
}

output "os_type" {
  description = "Operating system kind the plan hosts (Linux, Windows, or WindowsContainer)."
  value       = azurerm_service_plan.this.os_type
}

output "sku_name" {
  description = "SKU the plan is running on."
  value       = azurerm_service_plan.this.sku_name
}

output "worker_count" {
  description = "Number of worker instances allocated to the plan."
  value       = azurerm_service_plan.this.worker_count
}

output "kind" {
  description = "Computed kind of the plan (e.g. Linux, app, elastic)."
  value       = azurerm_service_plan.this.kind
}

How to use it

Provision a zone-redundant Linux Premium v3 plan, then schedule a Web App onto it via the module’s id output:

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

  name                = "asp-kloudvin-prod-cin"
  resource_group_name = azurerm_resource_group.app.name
  location            = azurerm_resource_group.app.location

  os_type  = "Linux"
  sku_name = "P1v3"

  # 3 workers across 3 zones for a production SLA.
  worker_count           = 3
  zone_balancing_enabled = true

  # Several apps share this plan; let the busy one scale on its own.
  per_site_scaling_enabled = true

  tags = {
    environment = "prod"
    workload    = "kloudvin-web"
    managed_by  = "terraform"
  }
}

# Downstream consumer: the Web App binds to the plan via its id output.
resource "azurerm_linux_web_app" "site" {
  name                = "app-kloudvin-prod-cin"
  resource_group_name = azurerm_resource_group.app.name
  location            = azurerm_resource_group.app.location
  service_plan_id     = module.app_service_plan.id

  site_config {
    always_on = true

    application_stack {
      node_version = "20-lts"
    }
  }

  https_only = true
}

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_plan/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-plan?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_plan && 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 Plan name (1-40 chars, letters/numbers/hyphens). Convention: asp-<workload>-<env>-<region>.
resource_group_name string Yes Resource group that contains the plan.
location string Yes Azure region (e.g. centralindia).
os_type string "Linux" No OS kind: Linux, Windows, or WindowsContainer. Immutable.
sku_name string "P1v3" No Plan SKU from the governed allow-list (Basic/Standard/Premium v2-v3/Isolated v2). Immutable in-place.
worker_count number 1 No Instance count (1-30). Must be a multiple of 3 when zone balancing is on.
per_site_scaling_enabled bool false No Let each app scale independently (Standard+).
zone_balancing_enabled bool false No Spread instances across zones. Create-time only; Pv3/Iv2 + worker_count ÷ 3.
maximum_elastic_worker_count number null No Max elastic workers (Premium elastic plans only).
app_service_environment_id string null No ASE v3 resource ID for an Isolated v2 plan; null for multi-tenant.
tags map(string) {} No Tags applied to the plan.

Outputs

Name Description
id Resource ID of the plan — pass to service_plan_id on Web/Function Apps.
name Name of the App Service Plan.
os_type OS kind the plan hosts (Linux/Windows/WindowsContainer).
sku_name SKU the plan is running on.
worker_count Number of worker instances allocated.
kind Computed plan kind (e.g. Linux, app, elastic).

Enterprise scenario

A retail platform team runs a customer-facing storefront, a checkout API, and an internal admin portal — all Linux Web Apps that must meet a 99.95% SLA in centralindia. They stand up a single zone-redundant P1v3 plan from this module with worker_count = 3 and zone_balancing_enabled = true, then schedule all three apps onto it, flipping per_site_scaling_enabled = true so a Black Friday spike on the storefront scales out without inflating the admin portal’s footprint. Because the module enforces the SKU allow-list and the worker-count-÷-3 precondition in CI, a platform engineer who later fat-fingers worker_count = 4 gets a plan failure at terraform plan instead of a silently non-redundant production fleet.

Best practices

TerraformAzureApp Service PlanModuleIaC
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