~/2026/08/12/azure-cost-governance-without-killing-developer-velocity.md
Azure: Cost Governance Without Killing Developer Velocity
--- author: Tom Lasswell date: read: 7 min in: [engineering, strategy] tags: [azure] ---
$ grep -n '^#' post.md
The first instinct after a surprise Azure bill is almost always to add an approval gate: someone has to sign off before a new resource gets created, or before a resource above some size gets provisioned. It works, in the narrow sense that it stops the specific mistake that triggered it, and it fails in the broader sense that every developer who wasn't the cause of that mistake now waits on a human for something that used to be self-service. I've watched this pattern play out enough times to trust a different order of operations: guardrails before gates, visibility before restriction, and automation before either. Below is each piece with the code I actually deploy.
Guardrails instead of gates
A gate asks permission before the fact; a guardrail makes the expensive mistake structurally hard to make in the first place. Azure Policy is the right tool for guardrails, and the built-in definitions cover the cost-relevant ones without writing custom JSON:
| Built-in policy | Definition ID | Parameter | Use |
|---|---|---|---|
| Allowed virtual machine size SKUs | cccc23c7-8427-4f53-ad12-b6a63eb452b3 | listOfAllowedSKUs | Cap VM sizes in non-production |
| Allowed locations | e56962a6-4747-49cd-b67b-bf8b01975c4c | listOfAllowedLocations | No accidental deployments to pricier or unapproved regions |
| Not allowed resource types | 6c112d4e-5bc7-47ae-a041-ea2d9dccd749 | listOfResourceTypesNotAllowed | Block specific expensive services in sandboxes |
| Require a tag on resource groups | 96670d01-0a4d-4649-9c89-2d3abc0a5025 | tagName | No resource group without a cost owner |
| Inherit a tag from the resource group if missing | ea3f2387-9b95-492a-a190-fcdc54f7b070 | tagName | Copy the cost tag down to every resource |
None of these asks a human to approve anything. They make the unintentional expensive path unavailable, while leaving the deliberate one open through an explicit exception process. The distinction that matters is that a guardrail fails fast, at deployment time, with a clear reason, where a gate fails slow, days later, with a queue. Azure Policy supports the "clear reason" part directly: an assignment's non-compliance message is shown in the deny error the developer sees, so it can say what to do instead.
This script assigns the non-production guardrails to a management group that holds dev/test subscriptions. The allowed-SKU list is an example; pick sizes from your own usage.
#!/usr/bin/env bash
# assign-nonprod-guardrails.sh: cost guardrails on a non-production management group.
set -euo pipefail
MG="<nonprod-management-group-id>"
SCOPE="/providers/Microsoft.Management/managementGroups/$MG"
LOCATION="eastus" # required for the modify assignment's managed identity
# Deny VM sizes outside an approved list.
az policy assignment create \
--name "nonprod-vm-skus" \
--display-name "Non-prod: allowed VM sizes" \
--scope "$SCOPE" \
--policy "cccc23c7-8427-4f53-ad12-b6a63eb452b3" \
--params '{ "listOfAllowedSKUs": { "value": [ "Standard_B2s", "Standard_B2ms", "Standard_D2s_v5", "Standard_D4s_v5" ] } }' \
--non-compliance-messages '[{ "message": "Non-production VMs are limited to B-series and small D-series. Ask the platform team for an exemption if you need more." }]'
# Every resource group needs a costCenter tag.
az policy assignment create \
--name "nonprod-rg-costcenter" \
--display-name "Non-prod: require costCenter on resource groups" \
--scope "$SCOPE" \
--policy "96670d01-0a4d-4649-9c89-2d3abc0a5025" \
--params '{ "tagName": { "value": "costCenter" } }' \
--non-compliance-messages '[{ "message": "Add a costCenter tag to the resource group." }]'
# Resources inherit costCenter from their resource group (modify effect, needs an identity with Contributor).
az policy assignment create \
--name "nonprod-inherit-cc" \
--display-name "Non-prod: inherit costCenter from resource group" \
--scope "$SCOPE" \
--policy "ea3f2387-9b95-492a-a190-fcdc54f7b070" \
--params '{ "tagName": { "value": "costCenter" } }' \
--mi-system-assigned \
--identity-scope "$SCOPE" \
--role Contributor \
--location "$LOCATION"
Assignment names at management group scope are limited to 24 characters, which is why they're terse. The modify assignment only tags resources as they're created or updated; existing ones show as non-compliant until you run a remediation task.
The exception process is a policy exemption with an expiry date, so it can't quietly become permanent:
# Waive the VM size guardrail for one resource group until the end of the quarter.
az policy exemption create \
--name "loadtest-large-vms" \
--display-name "Load test: large VMs until 2026-09-30" \
--policy-assignment "/providers/Microsoft.Management/managementGroups/<nonprod-management-group-id>/providers/Microsoft.Authorization/policyAssignments/nonprod-vm-skus" \
--exemption-category Waiver \
--expires-on "2026-09-30T23:59:59Z" \
--resource-group "<resource-group>" \
--description "Approved in <ticket-id>"
A tag-based guardrail also pays off in reporting. If you're on an EA, MCA or MPA with an Azure plan, Cost Management's tag inheritance setting applies subscription and resource group tags to child resource usage records (not to the resources themselves), within 24 hours for the current month. Between that and the modify policy, the costCenter split in cost analysis stops having a big "untagged" bucket.
Where a gate is still worth the friction
I'm not against approval workflows entirely. They're the right tool for the small number of decisions that are genuinely expensive to reverse: reservation and savings plan purchases, anything else with a multi-year commitment, and cross-region data transfer architecture decisions. Undoing those costs real money or real rework. The mistake is applying that same friction to routine, reversible actions like spinning up a dev VM or a test database, where the cost of a wrong decision is measured in dollars per day and the cost of the gate is measured in developer-hours per week across the whole team.
Commitments deserve the gate because they're easy to get wrong in both directions. Microsoft's own guidance frames the choice: a reservation commits to a specific instance type or family in a specific region and gives the greatest savings when fully used; a savings plan commits to an hourly spend across eligible compute services in any region and suits workloads that change. The recommended order is to right-size first ("Discounts reduce rates, not waste"), exchange or trade in underused reservations, and only then buy new reservations for stable workloads and savings plans for the flexible remainder. That sequence is a checklist a reviewer can hold a purchase request against.
The other structural lever is the subscription offer itself. Subscription vending in the Cloud Adoption Framework asks at request time whether a workload is production or DevTest, because the DevTest offer has lower resource charges under its own terms (and isn't available under an MPA). Getting non-production onto the right offer is a one-time decision at vending, not an ongoing approval.
Visibility changes behavior faster than restriction does
The governance move with the best ratio of effort to result, by a wide margin, has been making cost visible to the people actually creating resources, not just to whoever reads the monthly report. A budget alert that reaches a finance inbox a week after the spend happened changes nothing about next month's behavior. A per-subscription or per-tag cost view that a team can see themselves changes behavior almost immediately; teams that can see their own trend line stop leaving test environments running over the weekend without anyone telling them to.
Budgets are the cheapest way to push that signal to the team instead of to finance. Know their limits: budgets alert, they don't stop anything ("Resources aren't affected, and your consumption isn't stopped"). Cost data typically lands within 8 to 24 hours and budgets are evaluated every 24 hours, so an alert is a next-day signal, not a real-time one. Forecasted thresholds help with that lag by warning when the month's projection crosses a line. At subscription and resource group scope a budget can also call an action group, which is how you route it to a team channel. This is the budget I deploy into every vended subscription, based on Microsoft's Bicep quickstart with a forecast threshold and role-based recipients added:
// budget.bicep: monthly subscription budget with actual and forecast alerts.
targetScope = 'subscription'
param budgetName string = 'monthly-subscription-budget'
param amount int
@description('First day of the current month, YYYY-MM-DD.')
param startDate string
param contactEmails array = []
@description('Optional action group resource IDs, for example one that posts to the team channel.')
param actionGroupIds array = []
resource budget 'Microsoft.Consumption/budgets@2023-11-01' = {
name: budgetName
properties: {
category: 'Cost'
amount: amount
timeGrain: 'Monthly'
timePeriod: {
startDate: startDate
}
notifications: {
forecast100: {
enabled: true
operator: 'GreaterThan'
threshold: 100
thresholdType: 'Forecasted'
contactEmails: contactEmails
contactRoles: [
'Owner'
'Contributor'
]
contactGroups: actionGroupIds
}
actual80: {
enabled: true
operator: 'GreaterThan'
threshold: 80
thresholdType: 'Actual'
contactEmails: contactEmails
contactRoles: [
'Owner'
]
contactGroups: actionGroupIds
}
actual100: {
enabled: true
operator: 'GreaterThan'
threshold: 100
thresholdType: 'Actual'
contactEmails: contactEmails
contactRoles: [
'Owner'
'Contributor'
]
contactGroups: actionGroupIds
}
}
}
}
# Deploy into one subscription. The start date must be the first of a month.
az account set --subscription "<subscription-id>"
az deployment sub create \
--name budget-deploy \
--location eastus \
--template-file budget.bicep \
--parameters amount=1500 startDate="$(date -u +%Y-%m-01)" \
contactEmails='["<team-dl>@example.com"]'
Routing roles like Owner and Contributor means the alert reaches whoever actually runs the subscription, which is the point. If you want budget alerts in Slack rather than email, the Node.js Slack bot for resource group budget alerts does that, and Cost Anomaly Alerts from the Cost Management API covers the spikes a monthly budget is too coarse to catch.
Automating the boring cleanup
The remaining category, the one guardrails and dashboards don't fully solve, is idle resource decay: dev environments nobody remembered to tear down, orphaned disks left behind after a VM was deleted, public IPs reserved and never attached. That's not a governance problem so much as a garbage collection problem, and it belongs in automation, not policy.
For VMs, the built-in auto-shutdown schedule is enough for most non-production machines. The time is UTC in hhmm format:
# Put a 23:00 UTC auto-shutdown on every VM tagged environment=dev in the current subscription.
az vm list --query "[?tags.environment=='dev'].id" --output tsv |
while read -r vmId; do
az vm auto-shutdown --ids "$vmId" --time 2300 --output none
echo "auto-shutdown set: $vmId"
done
For orphans, this weekly sweep flags unattached managed disks (managedBy is null, the same test Microsoft's own cleanup script uses) and public IPs with no IP configuration or NAT gateway. The first run tags them with the date they were found; later runs report anything flagged longer than the grace period, and delete it only when you pass --delete.
#!/usr/bin/env bash
# orphan-sweep.sh [--delete]: flag, then after a grace period remove, orphaned disks and public IPs.
set -euo pipefail
GRACE_DAYS=14
TAG="orphanFlaggedOn"
DELETE=false
[[ "${1:-}" == "--delete" ]] && DELETE=true
TODAY=$(date -u +%F)
CUTOFF=$(date -u -d "-${GRACE_DAYS} days" +%F)
sweep() {
local kind="$1" query="$2"
az $kind list --query "$query" --output tsv |
while IFS=$'\t' read -r id flagged; do
if [[ -z "$flagged" || "$flagged" == "None" ]]; then
az resource tag --ids "$id" --tags "$TAG=$TODAY" --is-incremental --output none
echo "flagged $id"
elif [[ "$flagged" < "$CUTOFF" || "$flagged" == "$CUTOFF" ]]; then
if $DELETE; then
az resource delete --ids "$id" --output none
echo "deleted $id (flagged $flagged)"
else
echo "expired $id (flagged $flagged, rerun with --delete)"
fi
else
echo "waiting $id (flagged $flagged)"
fi
done
}
for sub in $(az account list --query "[?state=='Enabled'].id" --output tsv); do
az account set --subscription "$sub"
echo "== subscription $sub"
sweep "disk" "[?managedBy==null].[id, tags.${TAG}]"
sweep "network public-ip" "[?ipConfiguration==null && natGateway==null].[id, tags.${TAG}]"
done
Run it from a pipeline with an identity that has Contributor on the subscriptions in scope, and send the output wherever the teams will see it. The grace period and the tag are what make deletion safe: anyone who still needs a disk has two weeks to remove the tag or attach it, and the tag shows in the portal next to the resource. Before trusting the delete path, run it for a few weeks in report-only mode and check what it would have removed. Automating the cleanup means the governance conversation with developers is only ever about the decisions that actually need a decision, not about nagging people to remember chores a script should be doing anyway.
The throughline across all of it is that governance earns developer trust by being fast and legible, not by being strict. A rule that's instant, explainable, and has a documented exception path gets followed. A rule that's slow and opaque gets worked around, quietly, the first time it's in someone's way, and a worked-around control is worse than no control at all, because it looks like coverage on paper that isn't there in practice.
References
- Azure Policy built-in policy definitions
- az policy assignment (Azure CLI)
- az policy exemption (Azure CLI)
- Azure Policy modify effect
- Group and allocate costs using tag inheritance
- Create and manage budgets
- Quickstart: Create a budget with Bicep
- Decide between a savings plan and a reservation
- Subscription vending (Cloud Adoption Framework)
- Find and delete unattached Azure managed disks (Azure CLI)