The App Service Plan Module: Small Enough to Fail Loudly
The smallest module in the catalog teaches the two biggest lessons: strict lookups that refuse to pass null downstream, and shared plans as standalone stacks that dozens of apps reference by nothing but a name.
The smallest template in the library provisions the resource everything else stands on. An App Service Plan is the actual compute — the VMs under your web apps and function apps — and in the estate I worked on it got its own module, its own stacks, and a design decision that made it the strictest citizen in the whole catalog. Seven inputs, no VNet wiring, no diagnostics, and the loudest failure mode in the library. Sometimes the smallest module is the best teacher.
This is part 3 of the Terraform Module Catalog, companion to the main Terraform on Azure series; the lookup-map chassis is covered in main-series Part 2, and the apps that sit on these plans were Part 1 and Part 2 of this one.
Seven inputs and a refusal
The whole surface: plan name, region, resource group, tags, an SKU object, and two nullable extras (kind, reserved). But look closely at how the template resolves its maps:
locals {
# Premium required in nearly all situations
sku_list = {
dev = { tier = "PremiumV3", size = "P1v3" }
staging = { tier = "PremiumV3", size = "P2v3" }
prod = { tier = "PremiumV3", size = "P3v3" }
}
sku = lookup(local.sku_list, local.env) # <- no third argument
kind = lookup(local.kind_list, local.env) # <- none here either
}
Every other template in the library writes lookup(map, key, null) — the forgiving form, where a missing environment key quietly becomes null and flows downstream to fail somewhere confusing, or worse, succeed as a default. This one omits the default everywhere. Spin up a qa workspace without adding a qa key and the plan dies immediately, at plan time, with the map name in the error message.
That strictness is not an accident of style; it matches what the module manages. The template's own comment says Premium is required in nearly all situations — private endpoints and VNet integration, the posture from Part 1, need it. A null SKU is not an acceptable outcome under any circumstance, so the code refuses to represent it. This is the design fork I keep returning to in this series: forgiving lookups for genuinely optional values, strict lookups for anything the resource cannot exist without. The library contained both forms, but only this template applied the rule consistently — and it's the template where getting it wrong would cost the most, because a plan misconfiguration doesn't break one app, it breaks the compute under all of them.
Two honest wrinkles even here. The template's dev SKU pairs tier = "Premiumv3" with a v2-generation size string — a tier and size that disagree with each other, in a seven-input module, which is a compact proof that small surfaces still deserve validation. And the template's provider.tf ships the standard two providers, including the hub-subscription alias configured from local.sub_shared_subscription_id — a local this template never defines, because the plan module is the one stack that needs nothing from the hub. The provider block was copied in wholesale with the chassis, referencing a value that doesn't exist. Copy-paste doesn't just propagate what you need; it faithfully propagates what you don't.
Shared plans as their own stacks
Now the estate pattern, which is the more transferable idea. In the deployed estate, App Service Plans were not created inside app stacks. A shared plan was its own stack — its own folder, pipeline, state file — and dozens of app and function stacks then referenced it by nothing more than a name string:
# in a function app stack, three repos away from the plan's code
app_service_plan_name_list = {
dev = "plan-shared-api-dev"
staging = "plan-shared-api-staging"
prod = "plan-shared-api-prod"
}
No terraform_remote_state, no data-source lookup by tag, no registry: the plan's name is the interface. The estate had roughly ninety functions packed onto a handful of shared plans this way, which is the economically sane way to run Functions on Premium — one warm plan, many apps — and the string convention meant zero runtime coupling between stacks. An app stack could plan and apply without the plan stack's state existing anywhere it could see.
The price is referential integrity: nothing checks the string. Rename a plan and every consumer breaks on its next apply — not now, not visibly, but whenever each team happens to run its pipeline over the following months. The estate never renamed a shared plan in anger, and I suspect that's not because nobody wanted to; it's because everyone understood, correctly, that a plan rename was an estate-wide migration wearing a one-line diff as a costume.
The plan stack grows up
The template exposes seven inputs; the deployed shared-plan stacks show what the module could really do once a real workload leaned on it. The production copies grew zone_redundant, maximum_elastic_worker_count, per_site_scaling, autoscale notification hooks, and — the bulk of the file — full autoscale profiles as lists of objects:
autoscaling_profiles_list = {
dev = [{
profile_name = "default"
capacity_default = "1"
capacity_minimum = "1"
capacity_maximum = "3"
profile_rules = [
{ metric_trigger_operator = "GreaterThan", metric_trigger_threshold = "60",
scale_action_direction = "Increase", scale_action_cooldown = "PT5M", ... },
{ metric_trigger_operator = "LessThan", metric_trigger_threshold = "40",
scale_action_direction = "Decrease", scale_action_cooldown = "PT5M", ... },
]
}]
prod = [ /* same rules, capacity_maximum = "10" */ ]
}
Scale out above 60% CPU, scale in below 40%, five-minute cooldowns, capped at three instances in dev and ten in production. Because it's all in the workspace map, the entire scaling policy of every environment sits in one file — the capacity ceiling difference between dev and prod is a two-character diff a reviewer can actually see. That's the chassis paying rent: autoscale rules are exactly the kind of config that usually lives in a portal blade, unreviewed and unversioned, drifting per environment. Here they're code, and the strict lookups guarantee a new environment states its scaling policy before it can exist.
There's an operational asymmetry hiding here too. App stacks in this estate deployed constantly; plan stacks deployed almost never — a shared plan is the kind of resource you apply twice a year. That's the worst possible traffic pattern for latent bugs (the storage article next in this series turns that observation into a law), and it's another argument for this template's strictness: a stack that runs rarely gets few chances to fail cheaply, so every failure it can have should be a plan-time failure. It also changes the review culture. A pull request against an app stack risks one service; a pull request against plan-shared-api-prod risks every app on the slab, at apply time, in the middle of whoever's business day. The estate treated plan changes with a formality no app change got — informally, by habit — which is exactly the sort of rule that ought to be encoded as a pipeline approval gate instead of tribal knowledge.
The meta-lesson of this module: strictness should be proportional to blast radius. The App Service stack from Part 1 serves one app and can afford forgiving lookups; the plan under thirty apps cannot. When you design a module catalog, don't pick one lookup discipline for the whole library — pick it per module, based on how many things fall over when the answer is wrong.
Next, the module that multiplied instead of growing: one storage account module wearing five thin wrappers — and what the least-used wrapper reveals about copy-paste as a manufacturing process.