top of page
  • 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.

Azure Portal Resource JSON showing the live tag inheritance setting with higher-scope container tags preferred.

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.

Azure Portal export run history showing the previous-month execution succeeded.
Azure Portal Storage container showing the export manifest and compressed CSV blob without displaying financial rows.

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:

  1. The request must be an Azure Budget notification.

  2. The budget name must match the workshop Automation variable.

  3. The reported threshold must be at least 80.

  4. 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.

Azure Portal Automation recent jobs showing completed PowerShell runbook executions.

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.

Azure Portal Logic App run history showing successful workflow executions for the deliberate test.

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.

Azure Portal Action Group showing the Logic App receiver used by the FinOps budget guardrail.

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.

Azure Portal budget alert configuration showing the 80 percent Actual cost threshold and Logic App 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.

Azure Portal VM overview cards showing the stop-tagged VM deallocated and the keep-tagged VM still 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


bottom of page