Mert Özen Further With Every Line
DevOps Cloud Fundamentals — #10

Cost Management in the Cloud: Pricing Models, Budget Alerts, Cost Allocation with Tags and Common Money-Losing Mistakes

Mert Özen Nov 15, 2026 14 min 10 views
Cost Management in the Cloud: Pricing Models, Budget Alerts, Cost Allocation with Tags and Common Money-Losing Mistakes

The way to keep a cloud bill under control is to know what you pay for item by item, set up a budget on day one and tag every resource. We calculate Bookstore's monthly cost from real list prices in the Retail Prices API and cover reservations and savings plans, why a budget doesn't stop spending, FOCUS exports, the FinOps cycle and why a stopped App Service keeps billing.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Azure CLI 2.92.0 and ShellCheck 0.11.0. The cost estimate tool really ran against the Azure Retail Prices API, which needs no sign-in; the budget and clean-up scripts were tried with a fake az command that never calls Azure. No budget or resource was created on Azure. Prices are list prices as of 14 November 2026 and they change.

Guess First

In the dev environment of Bookstore, the online bookstore used as the shared example across this site's series, the API runs on a B1 App Service plan. To save money, the team stops the app every evening with az webapp stop and starts it in the morning. How much does the plan's monthly bill drop? Second question, from the production estimate: is 300 GB of internet traffic a month more expensive, or 30 GB of logs? Both answers surprise most teams.

What Are You Paying For?

A cloud bill isn't a single number but the sum of several different measures. Compute is paid by the hour or second: the App Service plan's instance hours, Container Apps' vCPU-seconds, SQL's vCore-hours. Storage is paid per GB stored per month. Operations (blob reads and writes, Functions executions, Cosmos DB request units) are paid per unit. Egress, traffic leaving Azure for the internet, is paid per GB; inbound traffic is free. Logs and telemetry are paid per GB written to the workspace. And there are fixed items that bill every hour whether used or not: private endpoints, NAT gateways, public IP addresses.

This split matters because the way to save depends on the item. You shrink or turn off compute, move storage to a cheaper tier, reduce egress with a CDN, filter logs, and the fixed items only go away when you delete them.

Pricing Models and AWS Equivalents

The default model is pay-as-you-go: no commitment, list price for what you use. For workloads that run all the time there are two commitment models. A reservation buys a specific resource type (a VM size, SQL vCores) for one or three years up front. A savings plan is an hourly spending commitment: according to Learn you commit to spend a fixed amount per hour for one or three years, unused commitment in an hour is lost, and usage above it is billed pay-as-you-go. The compute savings plan covers VMs, App Service, Functions Premium and Container Apps; there's now a separate savings plan type for databases as well. Note a change on Learn: reservations bought on or after 1 February 2027 can no longer be exchanged if a savings plan covers that service.

Spot VMs sell Azure's spare capacity at discounts of "up to 90%" against pay-as-you-go; in return, Azure takes the machine back when it needs the capacity. They suit interruptible batch work, not Bookstore.Api. Azure Hybrid Benefit lets you use your Windows Server and SQL Server licenses (with Software Assurance) on Azure. The table below gives the AWS equivalents of these models and of the tools in the rest of the post.

AzureAWS equivalentNote
ReservationReserved InstancesA specific resource type, 1 or 3 years
Savings plan (compute, database)Savings PlansHourly spending commitment; AWS says discounts of "up to 72%"
Spot VMSpot InstancesBoth quote discounts of "up to 90%"
Cost Management cost analysisCost ExplorerBreaking cost down by dimension
Budget and action groupAWS Budgets and budget actionsAWS can apply a policy when a threshold is crossed
Anomaly alertCost Anomaly DetectionReporting unexpected increases
Cost exportData ExportsGetting raw cost data out

Bookstore's Monthly Estimate

The Azure Pricing Calculator is the official way to estimate item by item. But the same prices are also in the Azure Retail Prices API, which can be queried without signing in, and that lets you keep the estimate as code: when a resource changes, the estimate changes in the same pull request. We wrote a small tool for Bookstore:

// Monthly list-price estimate for Bookstore, from the public Azure Retail Prices API (no sign-in needed).
// Run: dotnet run tools/cloud/cost-estimate.cs -- prod   (or dev)
using System.Text.Json;

const double HoursPerMonth = 730; // the month the Azure pricing pages use
string environment = args.FirstOrDefault() ?? "prod";

// Label, OData filter, meter name, monthly quantity in the meter's unit
(string Label, string Filter, string Meter, double Quantity)[] items = environment == "dev"
    ?
    [
        ("App Service B1 Linux, 1 instance", AppService("Azure App Service Basic Plan - Linux", "B1"), "B1", HoursPerMonth),
        ("Azure SQL free offer", "", "", 0),
        ("Blob hot LRS, 5 GB", Blob, "Hot LRS Data Stored", 5),
        ("Log Analytics ingestion, 3 GB", Logs, "Analytics Logs Data Ingestion", 3),
        ("Internet egress, 20 GB", Egress, "Standard Data Transfer Out", 20),
    ]
    :
    [
        ("App Service P0v3 Linux, 2 instances", AppService("Azure App Service Premium v3 Plan - Linux", "P0v3"), "P0v3 App", 2 * HoursPerMonth),
        ("Azure SQL General Purpose, 2 vCores", Sql, "vCore", HoursPerMonth),
        ("Blob hot LRS, 50 GB", Blob, "Hot LRS Data Stored", 50),
        ("Private endpoints, 2", PrivateEndpoint, "Standard Private Endpoint", 2 * HoursPerMonth),
        ("NAT gateway, 1", Nat, "Standard Gateway", HoursPerMonth),
        ("Log Analytics ingestion, 30 GB", Logs, "Analytics Logs Data Ingestion", 30),
        ("Internet egress, 300 GB", Egress, "Standard Data Transfer Out", 300),
    ];

using HttpClient http = new() { BaseAddress = new Uri("https://prices.azure.com/") };
double total = 0;
Console.WriteLine($"Bookstore {environment}, westeurope, USD list prices retrieved {DateTime.UtcNow:yyyy-MM-dd}");
foreach ((string label, string filter, string meter, double quantity) in items)
{
    double cost = filter.Length == 0 ? 0 : TieredCost(await GetTiersAsync(filter, meter), quantity);
    total += cost;
    Console.WriteLine($"  {label,-38} {cost,9:0.00}");
}
Console.WriteLine($"  {"Total per month",-38} {total,9:0.00}");

async Task<List<(double From, double Price)>> GetTiersAsync(string filter, string meter)
{
    string url = "api/retail/prices?$filter=" + Uri.EscapeDataString($"{filter} and meterName eq '{meter}' and priceType eq 'Consumption'");
    using JsonDocument doc = JsonDocument.Parse(await http.GetStringAsync(url));
    return doc.RootElement.GetProperty("Items").EnumerateArray()
        .Select(i => (From: i.GetProperty("tierMinimumUnits").GetDouble(), Price: i.GetProperty("retailPrice").GetDouble()))
        .Distinct()
        .OrderBy(t => t.From)
        .ToList();
}

// Each tier's price applies from its minimum to the next tier's minimum (e.g. the first 100 GB of egress are free)
static double TieredCost(List<(double From, double Price)> tiers, double quantity) =>
    tiers.Select((tier, i) =>
    {
        double upTo = i + 1 < tiers.Count ? tiers[i + 1].From : double.MaxValue;
        return Math.Max(0, Math.Min(quantity, upTo) - tier.From) * tier.Price;
    }).Sum();

static string AppService(string product, string sku) =>
    $"serviceName eq 'Azure App Service' and armRegionName eq 'westeurope' and productName eq '{product}' and skuName eq '{sku}'";

partial class Program
{
    const string Sql = "serviceName eq 'SQL Database' and armRegionName eq 'westeurope' and productName eq 'SQL Database Single/Elastic Pool General Purpose - Compute Gen5' and skuName eq '2 vCore'";
    const string Blob = "serviceName eq 'Storage' and armRegionName eq 'westeurope' and productName eq 'General Block Blob v2' and skuName eq 'Hot LRS'";
    // Private Link and NAT Gateway are priced in the "Global" region
    const string PrivateEndpoint = "serviceName eq 'Virtual Network' and armRegionName eq 'Global' and productName eq 'Virtual Network Private Link'";
    const string Nat = "serviceName eq 'NAT Gateway' and armRegionName eq 'Global' and skuName eq 'Standard'";
    const string Logs = "serviceName eq 'Log Analytics' and armRegionName eq 'westeurope' and skuName eq 'Analytics Logs'";
    const string Egress = "serviceName eq 'Bandwidth' and armRegionName eq 'westeurope' and productName eq 'Rtn Preference: MGN' and skuName eq 'Standard'";
}

The tool fetches each item's price from the API and accounts for tiered prices (such as the first 100 GB of egress being free). For production:

$ dotnet run tools/cloud/cost-estimate.cs -- prod
Bookstore prod, westeurope, USD list prices retrieved 2026-11-14
  App Service P0v3 Linux, 2 instances       129.94
  Azure SQL General Purpose, 2 vCores       244.46
  Blob hot LRS, 50 GB                         0.98
  Private endpoints, 2                       14.60
  NAT gateway, 1                             32.85
  Log Analytics ingestion, 30 GB             74.75
  Internet egress, 300 GB                    17.40
  Total per month                           514.98

Here's the answer to the second question: 30 GB of logs costs more than four times as much as 300 GB of egress. Every GB written to Analytics Logs costs 2.99 dollars after the first 5 GB; the first 100 GB of egress are free, then 0.087 dollars per GB. The 50 GB of covers don't even reach a dollar. An API writing a few lines of logs and traces for every request reaches 30 GB a month quickly. The dev environment's estimate:

$ dotnet run tools/cloud/cost-estimate.cs -- dev
Bookstore dev, westeurope, USD list prices retrieved 2026-11-14
  App Service B1 Linux, 1 instance           13.14
  Azure SQL free offer                        0.00
  Blob hot LRS, 5 GB                          0.10
  Log Analytics ingestion, 3 GB               0.00
  Internet egress, 20 GB                      0.00
  Total per month                            13.24

This is an estimate, not a bill. List prices can differ under enterprise agreements, taxes aren't included, and Log Analytics' free 5 GB really applies per billing account while the tool counts it per resource. The point is to see the orders of magnitude: what costs ten dollars and what costs a hundred.

Microsoft Cost Management

You see real spending in Cost Management. Cost analysis breaks cost down by resource group, service, region or tag. A budget defines a monthly amount for a scope and sends a notification when actual or forecasted cost crosses a threshold. The point Learn underlines is this: a budget doesn't affect resources or stop consumption; it only informs. Cost data also arrives with an 8-24 hour delay, so a budget won't catch a one-hour spike in the same hour. Anomaly alerts run once a day at subscription level and compare spending with a forecast built from the last 60 days. Azure Advisor also lists idle or oversized resources as cost recommendations.

To get the raw data out there are exports. FOCUS, a common schema for cloud providers' cost data, lets you read the bills of different clouds with the same columns. In Learn's schema list Azure's FOCUS export appears as 1.2-preview; the latest version published by the FinOps Foundation is 1.4, ratified in June 2026. If you use more than one cloud or analyse costs in your own data warehouse, choose this format.

Setting Up the Budget with a Script

We want the budget set up on day one, by a script rather than by hand in the portal. A problem we saw in post 2 is still here: az consumption budget create is still in preview and can't define notifications. So the script calls the REST API directly with az rest:

#!/usr/bin/env bash
# Monthly budget for everything tagged app=bookstore: an alert at 80% of actual cost and one at 100% forecast.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
AMOUNT="${1:?Usage: 10-budget.sh <monthly amount in the billing currency>}"
RG="rg-bookstore-prod-weu"
BUDGET="budget-bookstore-monthly"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"

# Who hears about it: e-mail for now; a webhook or runbook can join the same group later
az monitor action-group create --name ag-bookstore-cost --resource-group "$RG" --short-name bscost \
  --action email finops finops@example.com
AG_ID=$(az monitor action-group show --name ag-bookstore-cost --resource-group "$RG" --query id --output tsv)

# "az consumption budget create" is in preview and can't set alerts, so the REST API is called directly
BODY=$(mktemp)
trap 'rm -f "$BODY"' EXIT
cat > "$BODY" <<JSON
{
  "properties": {
    "category": "Cost",
    "amount": ${AMOUNT},
    "timeGrain": "Monthly",
    "timePeriod": { "startDate": "$(date -u +%Y-%m-01)T00:00:00Z" },
    "filter": { "tags": { "name": "app", "operator": "In", "values": ["bookstore"] } },
    "notifications": {
      "actual-80": {
        "enabled": true, "operator": "GreaterThan", "threshold": 80, "thresholdType": "Actual",
        "contactGroups": ["${AG_ID}"], "locale": "en-us"
      },
      "forecast-100": {
        "enabled": true, "operator": "GreaterThan", "threshold": 100, "thresholdType": "Forecasted",
        "contactGroups": ["${AG_ID}"], "locale": "en-us"
      }
    }
  }
}
JSON

az rest --method put --body "@${BODY}" \
  --url "https://management.azure.com/subscriptions/${AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Consumption/budgets/${BUDGET}?api-version=2026-06-01"

The budget covers everything tagged app=bookstore; that's why the tag policies from post 2 matter. There are two notifications: when actual cost passes 80 percent and when the end-of-month forecast passes 100 percent. Notifications go to an action group; today that's only e-mail, but by adding a webhook or a runbook to the same group the notification can also drive automation. We ran the script with a fake az that prints the calls and never reaches Azure:

$ bash infra/cli/10-budget.sh 600
+ az account set --subscription 00000000-0000-0000-0000-000000000000
+ az monitor action-group create --name ag-bookstore-cost --resource-group rg-bookstore-prod-weu --short-name bscost --action email finops finops@example.com
+ az monitor action-group show --name ag-bookstore-cost --resource-group rg-bookstore-prod-weu --query id --output tsv
+ az rest --method put --body @/tmp/tmp.GYwWWkYJfx --url https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Consumption/budgets/budget-bookstore-monthly?api-version=2026-06-01
body is valid JSON; notifications: actual-80, forecast-100

This doesn't show that Azure accepts the budget; it shows that the script produces the right calls with the right body.

Cost Allocation with Tags and FinOps

The answer to "how much does Bookstore cost a month?" comes from tags. In post 2 we put the app, environment, owner and cost-center tags on every resource group, and set up policies that reject a missing tag and copy it from the resource group to resources. Grouping cost analysis by cost-center shows which team spends what. To split the cost of shared resources (such as a common log workspace) between teams there are cost allocation rules, but according to Learn they only work for Enterprise Agreement and Microsoft Customer Agreement customers.

FinOps is this turned into a team habit. The FinOps Foundation's framework has three phases: Inform (making cost visible and assigned to an owner), Optimize (reducing waste, choosing the right size and the right pricing model) and Operate (tying it into a continuous process). At Bookstore's scale that's simple: every resource has tags, in the first week of every month Selin and the team look at cost analysis, and every new service comes with a cost estimate.

Mistakes That Lose Money

Forgotten environments. A preview environment opened for a pull request, a large database set up for a load test, a trial subscription nobody deleted. The fix is to put an end-date tag (expires-on) on every temporary resource group and list the expired ones regularly:

#!/usr/bin/env bash
# Finds resource groups whose expires-on tag (yyyy-mm-dd) is in the past; deletes them only with --delete.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
TODAY=$(date -u +%Y-%m-%d)

az account set --subscription "$AZURE_SUBSCRIPTION_ID"

# ISO dates sort as strings, so a string comparison is a date comparison
EXPIRED=$(az group list --output tsv \
  --query "[?tags.\"expires-on\" && tags.\"expires-on\" < '${TODAY}'].name")

for RG in $EXPIRED; do
  if [[ "${1:-}" == "--delete" ]]; then
    az group delete --name "$RG" --yes --no-wait
    echo "deleting ${RG}"
  else
    echo "would delete ${RG} (run with --delete)"
  fi
done

The query compares dates as strings; in yyyy-mm-dd format that gives the same result as comparing dates. We tried it with the JMESPath library Azure CLI uses, against a sample list of four resource groups:

$ bash infra/cli/10-cleanup-expired.sh
+ az account set --subscription 00000000-0000-0000-0000-000000000000
+ az group list --output tsv --query [?tags."expires-on" && tags."expires-on" < '2026-11-14'].name
would delete rg-bookstore-pr1042-weu (run with --delete)

Only the group whose end date has passed was listed; groups with a future date or no tag aren't on the list. By default the script deletes nothing.

A stopped app that keeps billing. The first answer to the guess: the bill doesn't drop at all. What App Service bills is the plan, not the app; according to Learn, the way to stop the charges is to delete the plan or scale it down to the Free tier. On 10 November 2026 plan-level stop arrived in public preview, and compute billing pauses for a stopped plan, but only for Premium v4 plans. If you want something in dev that drops to zero overnight, Container Apps' scale to zero or SQL's free offer are better tools.

Log and telemetry explosions. As the estimate showed, this is usually the fastest-growing item. A log left at debug level, a middleware that writes every request with its full body, or traces without sampling can multiply the bill within weeks. The Observability series covers sampling and what to collect in detail; a daily cap on the workspace is the last line of defence.

Leftovers that aren't deleted. According to Learn, when a VM is deleted, its disks and network resources can stay behind unless the delete option was set when it was created. Orphaned disks, snapshots and public IPs bill every month without anyone noticing. Oversized SKUs belong to the same group: a P3v3 plan chosen "because we might need it later" costs several times as much as a P0v3. Ask once more whether the NAT gateway and private endpoints from post 9 are really needed in the dev environment.

In Production

Enter commitment models with at least a few months of usage data. A reservation or savings plan means paying for capacity you don't use as well; Advisor's recommendations are calculated from past usage.

Set up the budget on the subscription's first day. Even on an account opened with free credit, a threshold and an e-mail stop a resource left on by mistake from going unnoticed until the end of the month.

Make price part of the architecture decision. Adding a line to the estimate tool when a new service comes in lets the person making the decision see the cost.

Common Pitfalls

Thinking a budget stops spending. It doesn't; it only notifies. If you want to stop spending you have to write automation attached to the action group, and you must be sure that automation won't shut down production.

Adding tags later. Tags don't change past cost records retroactively; months that went by untagged stay untagged.

Not checking the currency and region on the price. The Retail Prices API returns dollars by default; you can ask for another currency with the currencyCode parameter. The same service is priced differently across regions.

A Small Experiment

★☆☆ Change cost-estimate.cs to work with currencyCode='EUR' and get the prod estimate in euros.

★★☆ Add Container Apps to the estimate: 2 million requests a month and one replica active 8 hours a day with 0.5 vCPU and 1 GiB of memory on average. Account for the free allowances and compare the result with the App Service line.

★★★ Design 10-cleanup-expired.sh to run every night in a GitHub Actions workflow: which identity should it run as, with which role, and how do you add human approval for the delete step? Post 10 of the GitHub Actions series, which covers passwordless deployment to Azure, is the starting point.

In the last post of the series we bring all these pieces together in an architecture review: the five pillars of the Well-Architected Framework, backup and disaster scenarios, lock-in and Infrastructure as Code as the next step.