Azure Cost Management: Budgets, Tags, and Advisor
You will set a budget with alerts, tag resources for showback, and act on Azure Advisor recommendations. By the end you will catch overspend early and cut waste before the monthly bill arrives.
Cloud bills have a nasty habit of surprising people: a VM left running over the weekend, a forgotten storage account, an oversized database nobody right-sized after a proof of concept. Azure gives you three native tools to fight this, and this lab wires them together into one working loop. Tags tell you who spent the money, budgets with alerts tell you when spending crosses a line, and Advisor tells you where the waste is. Everything here uses the Azure CLI so the whole setup is repeatable and scriptable.
The lab assumes a personal or lab subscription, not an enterprise agreement. All commands run with a Contributor-level account; no paid third-party tooling is involved.
Learning Objectives
- Create a budget with threshold alerts delivered to an action group.
- Tag resources to attribute cost by project, and verify the tags stuck.
- Apply Advisor cost recommendations to eliminate idle and oversized resources.
- Break spend down per tag in Cost Analysis so showback reports are possible.
- Enforce the tagging convention with Azure Policy so governance survives future deployments.
- Time: ~2 hours · Difficulty: Beginner · Prereqs: an Azure subscription with some resources.
Architecture Overview
The governance loop is deliberately simple. Every resource carries tags, so Cost Analysis can group spend by project. A budget watches the total and fires alerts at thresholds you choose. In parallel, Advisor continuously scans usage patterns and emits recommendations, right-size this VM, delete that orphaned disk. Acting on those recommendations closes the loop and drops the spend line.
Notice what is not in the diagram: there is no automation shutting things down yet. This lab is about visibility and guardrails first. Once you trust the numbers, the Extension Ideas section points at scheduled auto-shutdown and cost exports.
Environment Setup
Prerequisites
- An Azure subscription with Cost Management Reader or Contributor, containing at least a few resources to analyze.
- Azure CLI 2.5x; a tagging convention in mind (for example
env,owner,project). - Assumed skills: resource groups and basic
azcommands.
You will need: the Azure CLI and a subscription with billable resources.
Before you begin: agree a tagging convention (for example project, env).
A tagging convention sounds trivial, but it is the decision everything else depends on. Tags are case-sensitive keys with case-insensitive values in some Azure surfaces and case-sensitive in others, so pick one style and stick to it. This lab uses project (which workload owns the cost) and env (lab, dev, or prod). Write the convention down before you touch a single resource, inconsistent tags like Project, project, and proj are the number one reason showback reports come out wrong.
If the CLI is not installed or you have not authenticated yet, do that first and confirm you are pointed at the right subscription:
SIGN IN AND SELECT THE SUBSCRIPTION
az login
az account list -o table
az account set --subscription "My Subscription"
az account show --query "{name:name, id:id, state:state}" -o table
The lab commands assume a resource group named tyf-rg that already holds a few billable resources (a VM and a storage account are plenty). If you need one:
az group create -n tyf-rg -l eastusaz account show reports. Verify the subscription name before creating anything.Step-by-Step Execution
Work through the steps in order. Steps 01-03 build the core loop (tags, budget, Advisor); steps 04-06 add the reporting and enforcement layers that make the setup hold up over time.
What: apply the agreed tags to every resource in tyf-rg in one pass. Why: Cost Analysis can only group spend by tags that actually exist on the resources. Untagged resources land in an "untagged" bucket, and once a bill is generated, historical spend cannot be re-tagged retroactively, the attribution only starts from the moment the tag is applied.
az resource tag --tags project=tyf env=lab --ids $(az resource list -g tyf-rg --query "[].id" -o tsv)The command resolves every resource ID in the group, then merges the two tags onto each one. By default az resource tag performs a merge: existing tags are kept and the new keys are added or overwritten. Passing --operation replace instead would wipe any tags already present, rarely what you want.
Tag the resource group itself too. Resource group tags are not inherited automatically (Step 05 fixes that with policy), but having them in place makes the inheritance policy meaningful:
az group update -n tyf-rg --set tags.project=tyf tags.env=labVERIFY, EVERY RESOURCE CARRIES BOTH TAGS
az resource list -g tyf-rg --query "[].{name:name, type:type, project:tags.project, env:tags.env}" -o table
Expected output:
$ az resource list --tag env=lab -o table
Name ResourceGroup Type
-------- --------------- ---------------------------------------
tyf-vm01 tyf-rg Microsoft.Compute/virtualMachines
tyf-vnet tyf-rg Microsoft.Network/virtualNetworks
Project/Env columns in the output above mean the tag command silently skipped resources, usually because a read-only lock exists on the resource or group. Check for locks with az lock list -g tyf-rg -o table before re-running. Verify: every row must show both tag values before moving on.What this teaches: Tags are the only way to attribute shared-subscription spend to teams and projects; design the convention before resources exist.
What: define a monthly spending cap and wire alert thresholds to an email action group. Why: a budget without notifications is just a chart, you only look at it after the damage is done. Alerts at 80% of actual spend and 100% of forecasted spend give you time to react while the month is still recoverable.
The minimal form creates a budget with default settings:
az consumption budget create --budget-name tyf-monthly --amount 50 --time-grain Monthly --category Cost --start-date $(date +%Y-%m-01) --end-date 2027-10-01The start and end dates are required: the start must be the first of a month, so the example computes it from today and the example sets an end date one year out; adjust both to your own window.
--category Cost means the budget tracks actual billed cost (including taxes and marketplace charges where applicable), as opposed to Usage, which tracks raw consumption. For personal guardrails, Cost is the right choice, it matches what the invoice will show.
Worth knowing before you create anything: budgets are scoped. Created with no extra flags (as here) a budget watches the whole subscription. Pass --resource-group tyf-rg to scope it to one group instead, useful for capping a single experiment without noise from the rest of the subscription. Enterprise environments can also scope budgets to billing accounts and management groups, but the CLI pattern stays the same.
To actually receive alerts, first create an action group with your email as a receiver, then attach it to the budget's notifications. An action group is Azure Monitor's reusable "who to notify" object, so the same group can later serve uptime alerts and security alerts too:
CREATE THE ACTION GROUP AND CAPTURE ITS ID
az monitor action-group create -g tyf-rg -n budget-alerts \
--short-name budgalrt \
--action email taki [email protected] -o table
AG_ID=$(az monitor action-group show -g tyf-rg -n budget-alerts --query id -o tsv)
echo $AG_ID
Expected output:
$ az monitor action-group show -g tyf-rg -n budget-alerts --query "{name:name, receivers:length(emailReceivers)}" -o table
Name Receivers
------------- -----------
budget-alerts 1
Before relying on the path, verify the receiver address: a typo in the email fails silently, no bounce, no error. Run the throwaway-budget test in the validation section and confirm the alert arrives (check spam for the first message from the Azure sender); only then trust the budget alerts.
Now recreate the budget with explicit start/end dates and two notification thresholds. Start dates for a Monthly grain must fall on the first of a month, and cannot be in the past beyond the current month:
BUDGET WITH 80% ACTUAL AND 100% FORECASTED ALERTS
az consumption budget create \
--budget-name tyf-monthly \
--amount 50 \
--category Cost \
--time-grain Monthly \
--start-date 2026-09-01 \
--end-date 2027-09-01 \
--notifications '{
"actual_GreaterThan_80_Percent": {
"enabled": true,
"operator": "GreaterThan",
"threshold": 80,
"contactEmails": ["[email protected]"],
"contactRoles": [],
"contactGroups": ["<paste the echoed action group ID here, the single quotes prevent $AG_ID from expanding>"]
},
"forecasted_GreaterThan_100_Percent": {
"enabled": true,
"operator": "GreaterThan",
"threshold": 100,
"contactEmails": ["[email protected]"],
"contactRoles": [],
"contactGroups": []
}
}'
Each notification key encodes its own condition: actual_GreaterThan_80_Percent fires when billed spend passes 80% of $50, and forecasted_GreaterThan_100_Percent fires when Azure's forecast predicts you will exceed the budget by month-end. The forecast alert is the early warning; the actual alert is the confirmation.
az monitor action-group show -g tyf-rg -n budget-alerts should list your email receiver.What this teaches: Budgets watch the future while alerts make them actionable; a budget nobody gets notified about is decoration.
What: pull Azure Advisor's Cost recommendations and act on them. Why: Advisor watches utilization signals you would have to dig out of metrics yourself, CPU averages, network throughput, disk IOPS, and turns them into concrete money-saving actions with an estimated saving attached.
The table view is enough to triage, but the real decision data lives in the recommendation's fields: which resource, what the problem is, and what Advisor suggests. Query them directly:
FULL RECOMMENDATION DETAIL
az advisor recommendation list --category Cost \
--query "[].{resource:impactedField, impact:impact, problem:shortDescription.problem, fix:shortDescription.solution}" \
-o table
Expected output:
$ az advisor recommendation list --query "[].{category:category, impact:impact}" -o table
Category Impact
---------- --------
Cost High
Cost Medium
Typical actions from this output:
- Right-size:
az vm resize -g tyf-rg -n tyf-vm01 --size Standard_B2sif the VM idles at 5% CPU. - Deallocate:
az vm deallocate -g tyf-rg -n tyf-vm01stops billing for compute (disks still bill). A plainstopfrom inside the OS does not stop compute charges. - Delete orphans: unattached disks and old public IPs keep billing after their parent VM is gone. Find disks with
az disk list --query "[?diskState=='Unattached']" -o table.
Advisor's Cost category recycles a handful of recommendation types. Knowing them by name makes the triage faster:
| Recommendation | Signal behind it | Typical fix |
|---|---|---|
| Right-size or shut down underutilized VMs | CPU/network under ~5% for 7+ days | Smaller SKU or deallocate |
| Buy reserved instances | Steady pay-as-you-go usage over 30 days | 1- or 3-year reservation, up to ~70% off |
| Delete unattached disks / public IPs | Resource billed with nothing consuming it | Delete after confirming no dependency |
| Right-size underutilized SQL / App Service | DTU or instance metrics far below capacity | Drop a service tier |
az vm get-instance-view -g tyf-rg -n tyf-vm01 --query instanceView.statuses[1].displayStatus -o tsv should read VM deallocated.What this teaches: Advisor recommendations are suggestions with dollar amounts attached; review each one before applying, since rightsizing is not always safe.
What: query month-to-date cost grouped by the project tag. Why: this is the showback payoff from Step 01, instead of one opaque subscription total, you get a per-project number you can hand to a team lead or use to decide which experiment is worth keeping.
MONTH-TO-DATE COST GROUPED BY THE project TAG
az costmanagement query --type ActualCost \
--timeframe MonthToDate \
--dataset-granularity None \
--dataset-grouping name="project" type="TagKey" \
-o table
Expected output:
Cost Analysis grouped by tag project: demo-web shows $18.42 month-to-date; untagged shows $6.10 month-to-date.
The (untagged) row is your hygiene metric: it represents spend you cannot attribute. The goal over time is to drive it to zero. A nonzero value right after tagging is normal, usage records from before the tags were applied stay untagged, and new cost data can take 8-24 hours to appear.
type="TagKey" in dataset grouping. If that happens, run the same breakdown in the portal: Cost Management › Cost analysis › Group by › Tag › project. The numbers are identical; only the interface differs. Verify: the rows sum to the subscription total shown in Cost Analysis for the same period.What this teaches: Cost Analysis turns raw meter lines into answers by grouping on the dimensions you care about, usually tags.
What: assign two built-in policies at the resource group scope: one requires the project tag on new resources, the other inherits it from the resource group. Why: manual tagging decays the moment someone deploys through the portal at midnight. Policy makes the convention self-enforcing so Step 04's untagged bucket stays empty without babysitting.
Built-in policies are referenced by ID, so look them up by display name first, never paste a GUID from a blog post without resolving it against your own tenant:
RESOLVE THE BUILT-IN POLICY IDS
REQ_ID=$(az policy definition list --query "[?displayName=='Require a tag on resources'].id" -o tsv)
INH_ID=$(az policy definition list --query "[?displayName=='Inherit a tag from the resource group'].id" -o tsv)
echo "$REQ_ID"; echo "$INH_ID"
Expected output:
{
"name": "require-owner-tag",
"properties": {
"enforcementMode": "Default",
"provisioningState": "Succeeded"
}
}
ASSIGN BOTH POLICIES TO tyf-rg
SCOPE=$(az group show -n tyf-rg --query id -o tsv)
az policy assignment create --name require-project-tag \
--policy "$REQ_ID" --scope "$SCOPE" \
--params '{"tagName":{"value":"project"}}'
az policy assignment create --name inherit-project-tag \
--policy "$INH_ID" --scope "$SCOPE" \
--params '{"tagName":{"value":"project"}}'
The Deny-effect policy blocks new deployments missing the tag; the Modify-effect inherit policy silently copies the resource group's tag onto anything created without one. Together they cover both the "refuse" and "auto-fix" philosophies, in a real environment you typically pick one.
az policy state list --resource-group tyf-rg -o table shows compliance once evaluation completes.What this teaches: Policy turns a tagging convention into a rule the platform enforces at deploy time, when it is cheapest to comply.
What: confirm the budget is tracking real spend and that the alerting path is live. Why: a governance setup you have never seen fire is a hypothesis, not a control.
BUDGET TRACKING CURRENT SPEND
az consumption budget show --budget-name tyf-monthly \
--query "{amount:amount, currentSpend:currentSpend.amount, unit:currentSpend.unit}" -o table
Expected output:
$ az consumption budget show --budget-name tyf-monthly --query "{amount:amount, currentSpend:currentSpend.amount, unit:currentSpend.unit}" -o table
Amount CurrentSpend Unit
-------- -------------- ------
50.0 36.29 USD
You cannot force a budget alert on demand, it fires only when cost data crosses a threshold. The practical test is to temporarily create a throwaway budget of --amount 1 (below your real spend) with your email attached; the 80% actual alert fires on the next evaluation cycle and proves the delivery path. Create it, wait for the alert email, then delete it:
az consumption budget create --budget-name tyf-alert-test --amount 1 --time-grain Monthly --category Cost --start-date $(date +%Y-%m-01) --end-date 2027-10-01 --notifications '{"actual_GreaterThan_80_Percent": {"enabled": true, "operator": "GreaterThan", "threshold": 80, "contactEmails": ["[email protected]"], "contactGroups": []}}'az consumption budget delete --budget-name tyf-alert-testWhat this teaches: The loop only works continuously: tag, budget, review, enforce, repeat. A one-time pass decays within weeks.
Step Flow Map
Each box is one step in the lab; work them in order, since later steps build on earlier state.
Progress So Far
The core loop from the architecture diagram is now live: resources are tagged, the budget is armed with alerts, and Advisor's waste list has been actioned. Steps 04-06 layered reporting and enforcement on top of that foundation.
What remains deliberately manual is the monthly ritual: skim the Advisor list, check the budget's current-spend figure, and clear any new untagged row in Cost Analysis. Ten minutes a month keeps the whole system honest, and the Extension Ideas below show how to automate even that away.
How It Works
Azure meters nearly every resource on a fine-grained schedule and records each meter as a usage line with a quantity and a rate. Cost Management is the lens over those lines: it aggregates by subscription, resource group, resource, meter, and tag. Tags are the only attribution mechanism that survives across all of these dimensions, which is why a tagging convention designed before resources exist beats one applied afterward.
There is a timing subtlety that trips up beginners: tags apply to cost data from the moment they are set, not retroactively. Spend incurred before tagging stays unattributed in the tag dimension forever. Budgets work the other direction: they watch forecasted and actual spend against a threshold and fire alerts through action groups, turning a number on a dashboard into an email or webhook someone will act on.
Azure Advisor and Azure Policy close the loop from opposite sides. Advisor is advisory: it recommends rightsizing, deleting idle resources, and buying reservations, and a human accepts or dismisses each one. Policy is enforcement: a deny effect stops a non-compliant deployment before it spends anything. Used together, Advisor finds the waste that already exists and Policy prevents the waste that has not happened yet.
Testing & Validation
Run these checks as a final acceptance pass. Each one maps to a step above; if any fails, jump back to that step's verify note.
az consumption budget show --budget-name tyf-monthly --query amount && az resource list -g tyf-rg --query "[?tags.project=='tyf'] | length(@)"You should see the budget amount and a count of tagged resources. With Advisor reviewed, cost governance is in place.
FULL ACCEPTANCE CHECKLIST
# 1. Budget exists, is Monthly, and tracks spend
az consumption budget list --query "[].{name:name, amount:amount, grain:timeGrain}" -o table
# 2. Alert delivery path is configured
az monitor action-group show -g tyf-rg -n budget-alerts \
--query "emailReceivers[].{name:name, email:emailAddress, status:status}" -o table
# 3. No untagged spend inside the lab group
az resource list -g tyf-rg --query "[?tags.project==null] | length(@)" -o tsv # expect: 0
# 4. Policies are assigned at the group scope
az policy assignment list --scope $(az group show -n tyf-rg --query id -o tsv) \
--query "[].displayName" -o table
# 5. Remaining Advisor cost items (expect fewer than Step 03, or none)
az advisor recommendation list --category Cost --query "length(@)" -o tsv
All five checks passing means the loop is closed: spend is attributed, capped, alerted, analyzed, and protected against drift.
Troubleshooting
These are the issues that actually bite people in this lab, in rough order of likelihood.
Budget alert never arrives
Symptom: spend is clearly past 80% but no email. Causes and fixes:
- The action group email receiver was never confirmed, find the Azure confirmation email and click the link, then check
az monitor action-group showlists the receiver. - The budget has no notification contacts attached at all (the minimal create command in Step 02 does exactly this). Recreate with the
--notificationspayload or add contacts in the portal blade. - Timing: budget evaluation follows cost-data ingestion, which can lag up to 24 hours. A threshold crossed today may alert tomorrow.
- Check spam folders, alert mail comes from
[email protected]and filters love eating it.
az consumption budget create fails with a date error
Symptom: an error about the time period or start date. Fix: for --time-grain Monthly, the start date must be the first day of a month and cannot be earlier than the current month. Use e.g. --start-date 2026-09-01. Also confirm --category is exactly Cost or Usage, other values are rejected.
Tag command runs but tags are missing afterwards
Symptom: Step 01's verify table shows blank columns. Fix: look for read-only locks (az lock list -g tyf-rg -o table) and remove or work around them. Remember that resource group tags do not flow down to resources on their own, either tag resources directly or rely on the inherit policy from Step 05 for future deployments.
Cost Analysis shows everything under "untagged"
Symptom: the Step 04 query returns one big untagged row. Fix: three usual reasons. Cost data has 8-24 hours of latency, so tags applied an hour ago are invisible yet. Tag application is not retroactive, spend from before tagging stays untagged forever. And tag keys are matched exactly, so Project vs project splits your data into two buckets.
Advisor returns an empty list
Symptom: Step 03 prints nothing. Fix: recommendations need usage history; check back after a day of activity. Freshly created or nearly-idle subscriptions legitimately have no Cost recommendations. Advisor also refreshes roughly daily, so acting on a recommendation does not clear it immediately, verify the fix with the resource's own state (e.g. VM deallocated) rather than waiting for Advisor to catch up.
Quick-reference for the failures you are most likely to hit in this lab.
| Symptom | Likely cause | Fix |
|---|---|---|
| Tags do not appear in Cost Analysis | Spend predates the tags, or the filter is on the wrong scope | Tags attribute only new spend; filter at the subscription scope and wait up to 24 hours for fresh data. |
| Budget alert never arrived | Contact emails missing, or the action group was never attached | Open the budget, confirm the alert rule has recipients, and send a test notification. |
| Advisor shows no recommendations | Insufficient history; Advisor needs about 24 hours of telemetry | Wait a day, then re-check; brand-new subscriptions are always quiet. |
| Policy deny blocks a legitimate deploy | The deployment violates the tagging convention | Add the required tags to the template or parameters; the deny is working as designed. |
| Cost data looks stale | Usage ingestion lags by 8 to 24 hours | Check again tomorrow before concluding a change had no effect. |
Extension Ideas
- Auto-shutdown dev VMs on a schedule to cut idle cost,
az vm auto-shutdown -g tyf-rg -n tyf-vm01 --time 1900pairs well with the deallocate habit from Step 03. - Export cost data and chart it in Azure Monitor workbooks.
- Detect anomalies as part of CSPM.
- Set a second, tighter budget scoped to just
tyf-rg(budgets accept a resource group scope) so lab experiments cannot hide inside the subscription total. - Extend the policy set with an
envtag requirement once theprojectrollout is stable.
Key Results
- Set a monthly budget with threshold alerts.
- Tagged resources to attribute 100% of project cost.
- Surfaced Advisor recommendations to cut waste.
- Established proactive spend governance before billing.
Key Takeaways
- Tag first, analyze second. Cost attribution is impossible to retrofit, historical spend keeps whatever tags existed when it was incurred.
- A budget without an action group is decoration. Always attach notifications, and always confirm the email receiver subscription.
- Forecast alerts are the early warning. The 100% forecasted alert fires while there is still time to act; the actual-spend alert just confirms it.
- Stopped is not deallocated. Only deallocated VMs stop billing for compute; disks and IPs keep charging either way.
- Policy beats discipline. Require/inherit tag policies keep the showback data clean long after the lab is over.
- Cost data is eventually consistent. Expect 8-24 hour lag in Cost Analysis and budget evaluation; never panic-test an alert and expect an instant email.
Practice Challenges
Challenge 1: Tiered budget alerts
Goal: Create one budget with alerts at 80, 100, and 120 percent, each routed to a different action group.
Hint: The 120 percent alert is the page-someone tier; keep its recipient list short.
Challenge 2: Require an owner tag
Goal: Write an Azure Policy assignment that denies any deployment missing the owner tag.
Hint: Start with the built-in Require a tag definition before writing custom policy.
Challenge 3: Pivot a month of spend
Goal: Export one month of cost data and pivot it by tag to find the single most expensive untagged resource.
Hint: Cost Management exports to storage; the CSV opens fine in any spreadsheet.