- 10 hours ago
- 8 min read

Build a hands-on Azure FinOps environment with tag inheritance, budgets, scheduled cost exports, Cost Management queries, and a least-privilege VM shutdown guardrail.
Introduction
FinOps becomes useful when cost visibility leads to a controlled action. In this workshop we build that complete path: consistent tags, Cost Management queries, a secure scheduled export, a monthly budget, and an automated guardrail that deallocates only workloads that explicitly opted in.
The lab uses PowerShell 7 as the orchestrator, Azure CLI for resource operations, and az rest for the Cost Management, Consumption, Logic Apps, Monitor, and Automation REST surfaces. The design is intentionally small enough for one session while retaining the boundaries you would want in a production implementation.
Timing and level
Level: intermediate to advanced.
Guided build: about 90–120 minutes.
Export availability can add 10–60 minutes depending on Cost Management processing.
Tag inheritance normally takes 8–24 hours to appear in usage records, so this single-session lab validates the setting but does not wait for propagation.
Cost warning
The lab creates two Standard_B1s Ubuntu VMs, managed disks, Storage, Azure Automation, a Logic App, and related control-plane resources. Run cleanup at the end. The budget is an alerting and automation signal; it is not a spending cap and does not stop charges by itself.
What you will prove
By the end of the workshop you will have evidence that:
the preflight stage made no Azure mutations;
two private VMs exist and no public IP address was created;
higher-scope tag precedence is configured and a conflicting resource tag exists for comparison;
the Cost Management 2025-03-01 Query API returns data or a documented no-data result after bounded retries;
a scheduled export completes and writes at least one blob without exposing its financial rows;
the Automation identity has only VM read, instance-view, deallocate, and resource-group read permissions at the lab scope;
a 79% synthetic budget event stops nothing;
an 80% event deallocates only the opt-in VM while the protected VM remains running;
pre-existing subscription budgets remain unchanged.
Architecture
subscription + resource-group tags
|
v
Cost Management
/ \
query + export monthly budget
| |
private Storage Monitor Action Group
|
Logic App
|
private Automation webhook
|
PowerShell 7.4 runbook
|
VM tag == finops-action=stop?
yes / \ no
deallocate keep running
The Action Group calls the Logic App with the Azure Budget payload. The workflow validates the schema and expected budget name before forwarding to the private Automation webhook. The runbook repeats the boundary checks and queries only the workshop resource group for VMs tagged finops-action=stop.
1. Run the read-only preflight
Set the target subscription and run preflight on its own:
$subscriptionId = az account show --query id --output tsv
./Start-AzureFinOpsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Location westeurope `
-BudgetAmount 10 `
-Stage Preflight
Preflight verifies PowerShell 7, Azure CLI authentication, the active subscription, required role-assignment authority, the existing subscription-budget names, and the original tag-inheritance state. It also refuses an unexpected pre-existing rg-finops-deepdive-we. Its runtime state is private and contains no public evidence.
2. Build the private foundation
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Foundation
The stage creates the resource group, virtual network, subnet, network security group, secure StorageV2 account, private blob container, two NICs, and two Ubuntu 22.04 Standard_B1s VMs. Neither NIC receives a public IP.
The storage controls require HTTPS and TLS 1.2 and disallow public blob access. The VM SSH key is generated under the private runtime directory and is removed during cleanup.
3. Apply financial ownership tags
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Tagging
The workshop applies workshop, environment, owner, and cost-center tags throughout the resource group. The shutdown candidate gets finops-action=stop. The protected VM gets finops-action=keep plus a deliberately conflicting cost-center=protected-workload value.
That conflict lets us discuss precedence without changing the resource itself: Cost Management inheritance changes usage records, not the resource tags visible on the VM.
4. Enable higher-scope tag precedence
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Inheritance
The script creates the subscription-scoped taginheritance Cost Management setting with higher-scope tags preferred. Preflight recorded that the setting was absent in the tested subscription, so cleanup later deletes it rather than leaving a new subscription setting behind.

Allow 8–24 hours for inherited tags to appear in usage records. Because this is a single-session workshop, the later query proves the tag grouping interface and current data response; it does not pretend that newly inherited tags have already propagated.
5. Query cost data by name, not position
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage CostQuery
The stage sends one combined query to the Cost Management 2025-03-01 endpoint for the last 90 days. It groups by service, resource group, and the cost-center tag. A single combined request is friendlier to the service quota than three independent queries.
HTTP 429 responses use a bounded retry loop. The script honors Retry-After or the Cost Management QPU retry header when present and otherwise uses a capped backoff. Returned rows are mapped using the response column names. No code depends on a hard-coded numeric column index.
If the subscription has no data in the requested window, NoData is a valid documented result. The public evidence records only status, row count, and response-column names—not financial values.
6. Create a valid daily export
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Export
The current Exports API schedule matrix requires a daily schedule to use month-to-date data. The workshop therefore creates an active daily month-to-date export and uses the Execute API’s explicit time period to run the previous completed month immediately.
The tested Visual Studio Enterprise/WebDirect agreement rejects the newer literal ActualCost. The script tries it first, then uses the documented Usage equivalent only for that agreement-specific error. It also lets that agreement select its supported dataset schema.
The stage polls run history until it reaches a successful terminal state and then proves at least one blob exists. It never opens the CSV or manifest content.


7. Give Automation a narrow identity boundary
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Guardrail
The Automation account uses a system-assigned managed identity and a dedicated PowerShell 7.4 runtime with the Az module. The temporary custom role contains only:
Microsoft.Compute/virtualMachines/read
Microsoft.Compute/virtualMachines/instanceView/read
Microsoft.Compute/virtualMachines/deallocate/action
Microsoft.Resources/subscriptions/resourceGroups/read
The assignment scope is the workshop resource group, not the subscription. The runbook can inspect VM state and request deallocation, but it cannot create, delete, resize, or retag VMs.
8. Publish the tagged-VM runbook
runbooks/Stop-FinOpsTaggedVm.ps1 validates four boundaries before acting:
The request must be an Azure Budget notification.
The budget name must match the workshop Automation variable.
The reported threshold must be at least 80.
The VM must be in the workshop resource group and tagged finops-action=stop.
Already-deallocated VMs are treated as an idempotent success. A rerun cannot cross into an untagged or protected workload.

9. Connect Logic App and private webhook
The Logic App receives the Azure Budget event, checks the schema, budget name, and threshold, and forwards only validated events to the private Automation webhook. Callback and webhook URLs exist only in the runtime state under work/; they never enter the public bundle.

10. Connect the Monitor Action Group
The Action Group uses one Logic App receiver with the Azure Budget payload rather than the common alert schema.

The chain is now complete:
Budget -> Action Group -> Logic App -> Automation webhook -> runbook
11. Create the monthly budget
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Budget
The budget is scoped to rg-finops-deepdive-we, resets monthly, and has a configured amount of 10 in the subscription billing currency. Its Actual threshold is GreaterThanOrEqualTo 80%, and the single contact group is the workshop Action Group.

The script records the existing subscription-budget baseline before creating this resource-group budget. Validation and cleanup compare against that baseline so an unrelated budget remains untouched.
12. Run the deliberate 79% test
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage TestGuardrail
The stage starts both VMs, waits until both report running, and posts a schema-accurate synthetic Azure Budget event with NotificationThresholdAmount=79. The Logic App accepts the event, but the runbook records no_action because the threshold is below the guardrail boundary.
Expected result: both VMs remain running.
13. Prove the 80% tag boundary
The same test stage sends an otherwise identical event with NotificationThresholdAmount=80. The runbook authenticates with its managed identity, enumerates VMs only in the workshop resource group, filters for the explicit opt-in tag, and requests deallocation.
Expected result:
vm-finops-stop becomes Stopped (deallocated).
vm-finops-keep remains Running.

This is a synthetic automation test, not a claim that the newly created budget naturally fired. Real Azure budgets are evaluated daily, and the cost data that feeds them is delayed.
14. Validate and capture sanitized evidence
./Start-AzureFinOpsWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Validate
./Get-AzureFinOpsEvidence.ps1 -SubscriptionId $subscriptionId
Validation checks ownership of the workshop resource group, two VMs, zero public IPs, the budget, three query dimensions, successful export blob proof, the 79%/80% guardrail boundary, and the original subscription-budget baseline.
The evidence collector publishes only safe proof: statuses, counts, tag values, configured threshold, permission actions, and power states. A sanitizer rejects GUID-shaped identifiers, tenant or subscription identifiers, principal IDs, callback and webhook URLs, public IPs, and actual financial rows.
Troubleshooting map
Symptom | Check | Resolution |
Preflight rejects the resource group | Existing rg-finops-deepdive-we has no matching private session tag | Use another subscription or remove the unrelated group yourself; the script will not adopt it |
Cost query returns 429 | Cost Management QPU throttling | Let the bounded retry honor the service header; rerun only after the stage exits |
Query reports NoData | No usage in the 90-day window | Treat it as valid lab evidence; do not invent cost rows |
ActualCost export is rejected | Agreement does not support the newer literal | Allow the script’s agreement-specific Usage equivalent fallback |
Export remains queued | Cost Management processing delay | Keep the terminal open; the stage polls for up to 60 minutes |
Export succeeds but no blob appears | Destination or agreement processing | Verify cost-exports, the run time period, and Storage permissions without opening financial rows |
Automation job fails before script output | Legacy runtime dependency issue | Use the included PowerShell 7.4 runtime environment and Az package |
79% changes a VM | Logic App or runbook threshold validation changed | Restore the supplied workflow and runbook before continuing |
Protected VM stops | Tag filter or scope boundary changed | Confirm finops-action=keep and the resource-group-scoped role assignment |
Production hardening
This workshop uses a secret webhook URL because it makes the control flow visible in one session. For production, place a durable API boundary in front of Automation, rotate secrets, use managed identities where supported, restrict ingress, and send workflow and job diagnostics to a monitored Log Analytics workspace.
Add approval tiers or a maintenance-window check before deallocation. Separate dev/test from production budgets, use multiple thresholds, attach ownership metadata, and keep allowlists for workloads that must never stop. Test idempotency and rollback, alert on failed Automation jobs, and review custom-role permissions through access reviews.
For exports, prefer a dedicated storage subscription, private endpoints or firewall-enabled export support, lifecycle policies, immutable retention where required, and strict access to financial data. Treat tag inheritance as reporting enrichment, not as a substitute for Azure Policy-based tag enforcement on resources.
Evidence summary
The completed live test proved:
two private VMs and zero public IPs;
three cost-query groupings parsed by returned column name;
bounded throttling recovery;
one successful previous-month export with two destination blobs;
four recent successful Logic App runs;
completed PowerShell 7.4 Automation jobs;
79% left both VMs running;
80% deallocated only the opt-in VM;
the protected VM remained running;
the pre-existing subscription budget remained unchanged.
No exported financial row or actual cost amount was read or included.
Cleanup
./Remove-AzureFinOpsWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-ConfirmCleanup
Cleanup deletes only the exact workshop budget, export, custom role assignment, custom role definition, and resource group. It restores Cost Management tag inheritance to the state captured during preflight. In this run the original setting was absent, so cleanup deletes the temporary setting. It then verifies the original subscription-budget baseline and removes the private runtime state, webhook URLs, raw identifiers, SSH key, and uncropped portal captures.
Final result
You have built a small but complete FinOps control loop: metadata improves reporting, REST queries and exports create evidence, a budget emits an operational signal, and a least-privilege guardrail acts only on workloads that explicitly opted in. Most importantly, the deliberate test proves where automation stops.
Microsoft references
Download the workshop files
Download the complete workshop ZIP containing the PowerShell scripts, Automation runbook, Logic App workflow, article, screenshots, and sanitized evidence.
Comments