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:
| Resource | Change | Before ($/mo) | After ($/mo) | Delta |
|---|---|---|---|---|
| vm-api-prod | Modify | $140.16 | $280.32 | +$140.16 |
| disk-data-01 | Create | $0.00 | $73.22 | +$73.22 |
| plan-webapp | NoChange | $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:
- Support Azure Savings Plans and Reservations — Query the Prices API with priceType eq 'Reservation' and show both pay-as-you-go and committed pricing
- Track cost trends over time — Store estimates in Azure Table Storage or a database and build a dashboard showing cost trajectory per environment
- Add Slack/Teams notifications — Alert the team channel when a PR exceeds the threshold
- Tag-based cost allocation — Parse resource tags from Bicep to attribute costs to teams or projects
- 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.