Blog Post

Azure Infrastructure Blog
7 MIN READ

Building Cost-Aware Azure Infrastructure Pipelines: Estimate Costs Before You Deploy

whosocurious's avatar
whosocurious
Icon for Microsoft rankMicrosoft
Apr 05, 2026

Every developer who works with Infrastructure as Code (IaC) has experienced this: you merge a pull request that changes a VM SKU, adds a new resource, or scales out a service — and then the next Azure bill arrives with a surprise. The infrastructure change was technically correct, but nobody validated the cost impact before it went live. What if your CI/CD pipeline could estimate the cost delta of every infrastructure change before deployment, post it as a PR comment, and block merges that exceed a budget threshold? In this post, I'll walk you through building exactly that — a cost-aware Azure infrastructure pipeline using Bicep, the Azure Retail Prices API, and GitHub Actions.

The Problem: Cost Is a Blind Spot in IaC Reviews

Code reviews for Bicep or Terraform templates typically focus on correctness, security, and compliance. But cost is rarely part of the review process because:

  • Developers don't have easy access to pricing data at review time
  • Azure pricing depends on region, tier, reservation status, and more
  • There's no built-in "cost diff" in any IaC tool

This means cost regressions slip through the same way bugs do when there are no tests.

iac-review-gap

Architecture Overview

Here's the pipeline we'll build:

architecture-overview

Step 1: Use Bicep What-If to Detect Changes

Azure's what-if deployment mode shows you exactly what resources will be created, modified, or deleted — without actually deploying anything.

az deployment group what-if --resource-group rg-myapp-prod --template-file main.bicep --parameters main.bicepparam --result-format ResourceIdOnly --out json > what-if-output.json

The JSON output contains a changes array where each entry has:

  • resourceId — the full ARM resource ID
  • changeType — one of Create, Modify, Delete, NoChange, Deploy
  • before and after — the full resource properties for modifications

This is the foundation: the what-if output tells us what is changing, and we can use that to look up what it costs.

what-if-cli-output

Step 2: Map Resources to Pricing with the Retail Prices API

The Azure Retail Prices API is a free, unauthenticated REST API that returns pay-as-you-go pricing for any Azure service.

Here's a Python script that takes a VM SKU and region and returns the monthly cost:

import requests

def get_vm_price(sku_name: str, region: str = "eastus") -> float | None:
    """Query the Azure Retail Prices API for a Linux VM's pay-as-you-go hourly rate."""
    api_url = "https://prices.azure.com/api/retail/prices"
    
    odata_filter = (
        f"armRegionName eq '{region}' "
        f"and armSkuName eq '{sku_name}' "
        f"and priceType eq 'Consumption' "
        f"and serviceName eq 'Virtual Machines' "
        f"and contains(meterName, 'Spot') eq false "
        f"and contains(productName, 'Windows') eq false"
    )
    
    response = requests.get(api_url, params={"$filter": odata_filter})
    response.raise_for_status()
    
    items = response.json().get("Items", [])
    if not items:
        return None
    
    hourly_rate = items[0]["retailPrice"]
    monthly_estimate = hourly_rate * 730  # avg hours per month
    return round(monthly_estimate, 2)


# Example usage
before_cost = get_vm_price("Standard_D4s_v5")   # e.g., $140.16/mo
after_cost = get_vm_price("Standard_D8s_v5")     # e.g., $280.32/mo
delta = after_cost - before_cost                  # +$140.16/mo

You can extend this pattern for other resource types — App Service Plans, Azure SQL databases, managed disks, etc. — by adjusting the serviceName and meterName filters.

Step 3: Build the GitHub Actions Workflow

Here's a complete GitHub Actions workflow that ties it all together:

name: Cost Estimate on PR

on:
  pull_request:
    paths:
      - "infra/**"

permissions:
  id-token: write      # For Azure OIDC login
  contents: read
  pull-requests: write  # To post comments

jobs:
  cost-estimate:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Run Bicep What-If
        run: |
          az deployment group what-if \
            --resource-group ${{ vars.RESOURCE_GROUP }} \
            --template-file infra/main.bicep \
            --parameters infra/main.bicepparam \
            --out json > what-if-output.json

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install requests

      - name: Estimate cost delta
        id: cost
        run: |
          python infra/scripts/estimate_costs.py \
            --what-if-file what-if-output.json \
            --output-format github >> "$GITHUB_OUTPUT"

      - name: Comment on PR
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: cost-estimate
          message: |
            ## 💰 Infrastructure Cost Estimate

            | Resource | Change | Before ($/mo) | After ($/mo) | Delta |
            |----------|--------|---------------|--------------|-------|
            ${{ steps.cost.outputs.table_rows }}

            **Estimated monthly impact: ${{ steps.cost.outputs.total_delta }}**

            _Prices are pay-as-you-go estimates from the Azure Retail Prices API. 
            Actual costs may vary with reservations, savings plans, or hybrid benefit._

      - name: Gate on budget threshold
        if: ${{ steps.cost.outputs.delta_value > 500 }}
        run: |
          echo "::error::Monthly cost increase exceeds $500 threshold. Requires finance team approval."
          exit 1

Step 4: The Cost Estimation Script

Here's the core of infra/scripts/estimate_costs.py that parses the what-if output and queries prices:

#!/usr/bin/env python3
"""Parse Bicep what-if output and estimate cost deltas using Azure Retail Prices API."""

import json
import argparse
import requests


PRICE_API = "https://prices.azure.com/api/retail/prices"

# Map ARM resource types to Retail API service names
RESOURCE_TYPE_MAP = {
    "Microsoft.Compute/virtualMachines": "Virtual Machines",
    "Microsoft.Compute/disks": "Storage",
    "Microsoft.Web/serverfarms": "Azure App Service",
    "Microsoft.Sql/servers/databases": "SQL Database",
}


def get_price(service_name: str, sku: str, region: str) -> float:
    """Query Azure Retail Prices API and return monthly cost estimate."""
    odata_filter = (
        f"armRegionName eq '{region}' "
        f"and armSkuName eq '{sku}' "
        f"and priceType eq 'Consumption' "
        f"and serviceName eq '{service_name}'"
    )
    resp = requests.get(PRICE_API, params={"$filter": odata_filter})
    resp.raise_for_status()
    items = resp.json().get("Items", [])
    if not items:
        return 0.0
    return items[0]["retailPrice"] * 730


def parse_what_if(filepath: str) -> list[dict]:
    """Extract resource changes from what-if JSON output."""
    with open(filepath) as f:
        data = json.load(f)

    results = []
    for change in data.get("changes", []):
        change_type = change.get("changeType", "")
        resource_type = change.get("resourceId", "").split("/providers/")[-1].split("/")[0:2]
        resource_type_str = "/".join(resource_type) if len(resource_type) == 2 else ""

        if resource_type_str not in RESOURCE_TYPE_MAP:
            continue

        before_sku = (change.get("before") or {}).get("sku", {}).get("name", "")
        after_sku = (change.get("after") or {}).get("sku", {}).get("name", "")
        region = (change.get("after") or change.get("before") or {}).get("location", "eastus")

        service = RESOURCE_TYPE_MAP[resource_type_str]
        before_price = get_price(service, before_sku, region) if before_sku else 0.0
        after_price = get_price(service, after_sku, region) if after_sku else 0.0

        results.append({
            "resource": change.get("resourceId", "").split("/")[-1],
            "change_type": change_type,
            "before": round(before_price, 2),
            "after": round(after_price, 2),
            "delta": round(after_price - before_price, 2),
        })

    return results


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--what-if-file", required=True)
    parser.add_argument("--output-format", default="text", choices=["text", "github"])
    args = parser.parse_args()

    changes = parse_what_if(args.what_if_file)
    total_delta = sum(c["delta"] for c in changes)

    if args.output_format == "github":
        rows = []
        for c in changes:
            sign = "+" if c["delta"] >= 0 else ""
            rows.append(
                f"| {c['resource']} | {c['change_type']} "
                f"| ${c['before']:.2f} | ${c['after']:.2f} "
                f"| {sign}${c['delta']:.2f} |"
            )
        print(f"table_rows={'chr(10)'.join(rows)}")
        sign = "+" if total_delta >= 0 else ""
        print(f"total_delta={sign}${total_delta:.2f}/mo")
        print(f"delta_value={total_delta}")
    else:
        for c in changes:
            print(f"{c['resource']}: {c['change_type']} "
                  f"${c['before']:.2f} → ${c['after']:.2f} "
                  f"(Δ ${c['delta']:+.2f})")
        print(f"\nTotal monthly delta: ${total_delta:+.2f}")


if __name__ == "__main__":
    main()

What the Developer Experience Looks Like

Once this pipeline is in place, every PR that touches infrastructure files gets an automatic cost comment:

ResourceChangeBefore ($/mo)After ($/mo)Delta
vm-api-prodModify$140.16$280.32+$140.16
disk-data-01Create$0.00$73.22+$73.22
plan-webappNoChange$69.35$69.35+$0.00

Estimated monthly impact: +$213.38/mo

 

If the delta exceeds a configurable threshold (e.g., $500/mo), the pipeline fails and requires explicit approval — just like a failing test. 

Extending This Further

Here are some ways to take this pipeline to the next level:

  1. Support Azure Savings Plans and Reservations — Query the Prices API with priceType eq 'Reservation' and show both pay-as-you-go and committed pricing
  2. Track cost trends over time — Store estimates in Azure Table Storage or a database and build a dashboard showing cost trajectory per environment
  3. Add Slack/Teams notifications — Alert the team channel when a PR exceeds the threshold
  4. Tag-based cost allocation — Parse resource tags from Bicep to attribute costs to teams or projects
  5. Multi-environment estimates — Run the pipeline against dev, staging, and prod parameter files to show total organizational impact

Key Takeaways

  • Azure's What-If API gives you a deployment preview without making changes — use it as the foundation for any pre-deployment validation
  • The Azure Retail Prices API is free, requires no authentication, and returns granular pricing data you can query programmatically
  • Cost gates in CI/CD treat budget overruns the same way you treat test failures — as merge blockers that require explicit action
  • Shift cost left — just like security and testing, catching cost issues at PR time is 10x cheaper than catching them on the monthly bill

Infrastructure cost is infrastructure quality. By integrating cost estimation into your pull request workflow, you give every developer on the team visibility into the financial impact of their changes — before a single resource is deployed.

Updated Apr 05, 2026
Version 1.0
No CommentsBe the first to comment