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
- You run one or more Web/Function/API Apps and need the shared Dedicated (or Isolated) compute they bind to.
- You want a governed SKU allow-list so teams can’t accidentally stand up an oversized
P3v3or a non-prodI3v2Isolated plan. - You need zone-redundant hosting (Pv3/Iv2) for a production SLA and want the worker-count-multiple-of-3 rule enforced, not left to chance.
- You’re packing several apps onto one plan and need per-app scaling (
per_site_scaling_enabled) so one noisy app doesn’t drag the whole fleet’s instance count up. - You manage many environments and want the plan defined once, parameterised by
os_type+sku_name, and reused via a pinned Git tag.
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 config — live/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 config — live/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
- Right-size before you scale out. A single
P1v3(2 vCPU / 8 GB) often beats threeB1instances on both performance and price — move up a SKU before adding workers, and reserve Isolated v2 for genuine network-isolation or compliance needs, not raw horsepower. - Treat
os_typeandsku_nameas immutable. Changing either replaces the whole plan and recreates every app on it. Plan SKU upgrades during a maintenance window, and never mix Linux and Windows apps expecting one plan to serve both. - Turn on zone redundancy at creation for production.
zone_balancing_enabledcan’t be added later — bake it in from day one on Pv3/Iv2 withworker_countas a multiple of 3, and let the module’s precondition stop a misconfigured fleet from shipping. - Use
per_site_scaling_enabledwhen packing apps. Co-locating apps on one plan is the cheapest path, but without per-site scaling the busiest app forces every app to scale together; enable it so each app scales on its own metrics. - Standardise naming and tagging through the module. Enforce
asp-<workload>-<env>-<region>and requireenvironment/managed_bytags so cost reports and policy assignments can slice plans cleanly across subscriptions. - Buy Reserved Instances or a Savings Plan for steady tiers. Production Premium v3 plans run 24/7; a 1- or 3-year reservation cuts the compute bill 30-55% versus pay-as-you-go for the same fleet.