# Azure FinOps Deep Dive: Budgets, Cost Exports, and Automated Guardrails

Build a hands-on Azure FinOps environment with tag inheritance, budgets, scheduled cost exports, Cost Management queries, and a least-privilege VM shutdown guardrail.

![Neon Azure FinOps workshop architecture showing tags flowing through Cost Management, exports, budgets, automation, and a stopped virtual machine.](azure-finops-deep-dive-cover.png)

## 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

```text
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:

```powershell
$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

```powershell
./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

```powershell
./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

```powershell
./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.](screenshots/01-tag-inheritance.png)

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

```powershell
./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

```powershell
./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.](screenshots/03-export-run-history.png)

![Azure Portal Storage container showing the export manifest and compressed CSV blob without displaying financial rows.](screenshots/08-export-container-blobs.png)

## 7. Give Automation a narrow identity boundary

```powershell
./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:

```text
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.](screenshots/06-automation-job-completed.png)

## 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.](screenshots/05-logic-app-success.png)

## 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.](screenshots/04-action-group-logic-app-receiver.png)

The chain is now complete:

```text
Budget -> Action Group -> Logic App -> Automation webhook -> runbook
```

## 11. Create the monthly budget

```powershell
./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.](screenshots/02-budget-thresholds.png)

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

```powershell
./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.](screenshots/07-vm-power-states.png)

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

```powershell
./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

```powershell
./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

- [Cost Management automation overview](https://learn.microsoft.com/en-us/azure/cost-management-billing/automate/automation-overview)
- [Group and allocate costs using tag inheritance](https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/enable-tag-inheritance)
- [Cost Management Query API 2025-03-01](https://learn.microsoft.com/en-us/rest/api/cost-management/query/usage?view=rest-cost-management-2025-03-01)
- [Cost Management Exports API 2025-03-01](https://learn.microsoft.com/en-us/rest/api/cost-management/exports/get?view=rest-cost-management-2025-03-01)
- [Run a Cost Management export](https://learn.microsoft.com/en-us/rest/api/cost-management/exports/execute?view=rest-cost-management-2025-03-01)
- [Create and manage exported data](https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/tutorial-export-acm-data)
- [Manage Azure Automation runtime environments](https://learn.microsoft.com/en-us/azure/automation/manage-runtime-environment)
- [Azure Automation webhooks](https://learn.microsoft.com/en-us/azure/automation/automation-webhooks)
