Reduce your Azure bill by 30-60% with this tactical guide. Covers reserved instances, right-sizing, spot VMs, storage tiering, and automated cleanup scripts.
Optimizing Azure cloud costs is essential when cloud spending grows faster than application usage, especially in multi-team, multi-environment setups where resources are provisioned without cost awareness. This guide provides a precise, actionable workflow for identifying, diagnosing, and reducing Azure cloud expenses through concrete steps, real tools, and real configurations—no fluff, no hypotheticals.
Begin by gathering detailed cost data across all Azure resources. Use the Azure Cost Management and Billing API to extract granular cost breakdowns. The primary endpoint is GET https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CostManagement/query?api-version=2021-10-01.
{
"type": "ActualCost",
"timeframe": "MonthToDate",
"interval": "Daily",
"dataset": {
"granularity": "Daily",
"grouping": [
{ "type": "Dimension", "name": "ResourceGroup" },
{ "type": "Dimension", "name": "ResourceType" },
{ "type": "Dimension", "name": "ServiceName" }
],
"filter": {
"dimensions": [
{ "name": "ResourceGroup", "operator": "In", "values": [ "prod", "staging", "dev" ] }
]
},
"aggregation": {
"total": { "name": "Cost", "function": "Sum" }
}
}
}
Use curl to fetch data and jq to filter and analyze:
curl -H "Authorization: Bearer $AZURE_TOKEN" \
"https://management.azure.com/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.CostManagement/query?api-version=2021-10-01" \
-d @cost-query.json \
| jq -r '.rows[] | [.timeDimension, .ResourceGroup, .ResourceType, .ServiceName, .Cost] | @tsv'
A common failure: timeframe is misinterpreted as startDate and endDate. The MonthToDate value does not include the current month’s data unless interval is set to Daily. Default aggregation is Sum, but Average or Maximum can be used for instance metrics like CPU utilization.
For virtual machines, analyze usage and select appropriate SKUs. Use the Azure Monitor Metrics API to retrieve CPU and memory utilization over the past 30 days.
GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Compute/virtualMachines/{vmName}/providers/Microsoft.Insights/metrics?metricnames=Percentage CPU,Memory Percentage&interval=PT1H&startTime=2023-09-01T00:00:00Z&endTime=2023-10-01T00:00:00Z&api-version=2018-01-01
Use jq to extract 95th percentile CPU and memory:
curl -H "Authorization: Bearer $AZURE_TOKEN" \
"https://management.azure.com/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/prod/providers/Microsoft.Compute/virtualMachines/web-01/providers/Microsoft.Insights/metrics?metricnames=Percentage CPU,Memory Percentage&interval=PT1H&startTime=2023-09-01T00:00:00Z&endTime=2023-10-01T00:00:00Z&api-version=2018-01-01" \
| jq -r '
.value[]
| select(.name.value == "Percentage CPU")
| .timeseries[].data
| .[] | .average
| sort | .[length-1] # 95th percentile
'
A key insight: a VM with 80% average CPU but 95th percentile at 92% indicates that the instance is under-provisioned. The timeGrain of PT1H (1 hour) is sufficient for most workloads, but for microservices with bursty traffic, use PT5M and timeGrain of PT1M for higher resolution.
Stop unused or underutilized VMs using Azure Automation Runbooks. Define a PowerShell script with Get-AzVM, Get-AzMetric, and Stop-AzVM.
$vm = Get-AzVM -ResourceGroupName "prod" -Name "web-01"
$cpu = Get-AzMetric -ResourceGroupName "prod" -ResourceId $vm.Id -MetricName "Percentage CPU" -StartTime "2023-09-01T00:00:00Z" -EndTime "2023-10-01T00:00:00Z" -TimeGrain (New-TimeSpan -Minutes 5)
$avgCpu = ($cpu | Select-Object -ExpandProperty Average).Average
if ($avgCpu -lt 20) {
Stop-AzVM -ResourceGroupName "prod" -Name "web-01" -Force
}
A silent failure: Stop-AzVM does not wait for the VM to reach "Stopped" state. Use -Wait flag for reliable shutdowns.
Azure Blob Storage costs depend on access tier, retention, and replication. Use the az storage account show command to inspect configuration:
az storage account show \
--name mystorageaccount \
--resource-group prod \
--query '{name: name, sku: sku.name, kind: kind, defaultEncryption: encryption.defaultEncryption, geoRedundancy: geoRedundancy, zoneRedundancy: zoneRedundancy}'
Output:
{
"name": "mystorageaccount",
"sku": "Standard_LRS",
"kind": "BlockBlobStorage",
"defaultEncryption": true,
"geoRedundancy": true,
"zoneRedundancy": false
}
For cold data, transition blobs from Hot to Cool tier using az storage blob move:
az storage blob move \
--account-name mystorageaccount \
--container-name logs \
--source-blob-name "2023/09/01.tar.gz" \
--destination-blob-name "2023/09/01.tar.gz" \
--tier Cool
The tier option accepts Hot, Cool, Archive, and Hot defaults to Hot. A common mistake: az storage blob move does not trigger tiering immediately. Use --if-modified-since to avoid redundant moves.
Enable lifecycle management via JSON policy in the azure-storage service:
{
"rules": [
{
"name": "archive-old-logs",
"enabled": true,
"type": "Lifecycle",
"definition": {
"filters": {
"prefixes": [ "logs/" ],
"blobTypes": [ "blockBlob" ]
},
"actions": {
"baseBlob": {
"tierToCool": {
"daysAfterModificationGreaterThan": 30
},
"tierToArchive": {
"daysAfterModificationGreaterThan": 90
}
},
"snapshot": {
"tierToCool": {
"daysAfterCreationGreaterThan": 30
}
}
}
}
}
]
}
Apply the policy with az storage account management-policy create:
az storage account management-policy create \
--account-name mystorageaccount \
--resource-group prod \
--policy @lifecycle-policy.json
A failure mode: az storage account management-policy create does not validate the JSON. If daysAfterModificationGreaterThan is misspelled as daysAfterModificationGreaterThen, the rule is silently ignored.
Define Azure Policy definitions to enforce cost-effective configurations across environments. Use az policy definition create with JSON.
{
"properties": {
"displayName": "Ensure VMs use Standard_LRS storage",
"description": "All VMs must use Standard_LRS storage for optimal cost-performance balance.",
"mode": "Indexed",
"parameters": {
"requiredSku": {
"type": "String",
"defaultValue": "Standard_LRS",
"allowedValues": [ "Standard_LRS", "Standard_GRS", "Premium_LRS" ],
"metadata": {
"description": "The required storage SKU for VMs."
}
}
},
"policyRule": {
"if": {
"field": "type",
"equals": "Microsoft.Compute/virtualMachines"
},
"then": {
"effect": "deployIfNotExists",
"details": {
"type": "Microsoft.Storage/storageAccounts",
"name": "[concat(parameters('requiredSku'), '-', field('name'))]",
"existenceCondition": {
"field": "properties.sku.name",
"equals": "[parameters('requiredSku')]"
},
"deployment": {
"properties": {
"mode": "Incremental",
"parameters": {
"storageAccountName": { "value": "[concat(parameters('requiredSku'), '-', field('name'))]" },
"sku": { "value": "[parameters('requiredSku')]" }
},
"template": {
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2021-04-01",
"name": "[parameters('storageAccountName')]",
"location": "[field('location')]",
"sku": {
"name": "[parameters('sku')]"
},
"kind": "StorageV2",
"properties": {
"encryption": {
"services": {
"blob": { "enabled": true },
"file": { "enabled": true }
},
"keySource": "Microsoft.Storage"
}
}
}
]
}
}
}
}
}
}
}
}
Apply the policy to a subscription:
az policy assignment create \
--name enforce-vm-storage-sku \
--display-name "Enforce VMs use Standard_LRS storage" \
--scope "/subscriptions/12345678-1234-1234-1234-123456789012" \
--policy "vm-storage-sku-policy.json"
A critical edge: mode: "Indexed" is required for deployIfNotExists to work efficiently. Without it, the policy runs over all resources, but only checks for existence in the details block. The deployment template can fail silently if parameters are not correctly mapped. Use az policy assignment show to debug.
Set up cost alerts using Azure Monitor. Create an alert rule that triggers when total monthly cost exceeds 120% of the previous month’s average.
{
"name": "MonthlyCostAlert",
"description": "Alert when monthly cost exceeds 120% of prior month average",
"severity": 2,
"actions": {
"actionGroups": [ "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/monitoring/providers/Microsoft.Insights/actionGroups/cost-alerts" ]
},
"condition": {
"allOf": [
{
"dataSource": {
"resourceUri": "/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.CostManagement/queries/monthly-cost",
"metricName": "Cost",
"metricNamespace": "Microsoft.CostManagement",
"aggregation": "Average",
"timeGrain": "PT1H"
},
"operator": "GreaterThan",
"threshold": 120,
"timeAggregation": "Average"
},
{
"dataSource": {
"resourceUri": "/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.CostManagement/queries/monthly-cost",
"metricName": "Cost",
"metricNamespace": "Microsoft.CostManagement",
"aggregation": "Average",
"timeGrain": "PT1H"
},
"operator": "GreaterThan",
"threshold": 100,
"timeAggregation": "Average",
"timeWindow": "P1M"
}
]
}
}
Use az monitor alert create to register the rule:
az monitor alert create \
--name monthly-cost-alert \
--resource-group monitoring \
--description "Monthly cost above 120% of prior month" \
--severity 2 \
--action "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/monitoring/providers/Microsoft.Insights/actionGroups/cost-alerts" \
--condition "metric=Cost,aggregation=Average,threshold=120,timeAggregation=Average,resourceUri=/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.CostManagement/queries/monthly-cost" \
--condition "metric=Cost,aggregation=Average,threshold=100,timeAggregation=Average,timeWindow=P1M,resourceUri=/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.CostManagement/queries/monthly-cost"
A subtle failure: the timeWindow parameter is not P1M but P1M is accepted. But timeWindow is applied to the condition and not the dataSource, leading to inconsistent results. Use --timeAggregation at both levels to ensure clarity.
Integrate cost optimization into CI/CD. Add a step in GitHub Actions to check for underutilized resources before merging to main.
- name: Check VM Utilization
run: |
az login --service-principal -u $AZURE_CLIENT_ID -p $AZURE_CLIENT_SECRET -t $AZURE_TENANT_ID
az account set --subscription $AZURE_SUBSCRIPTION_ID
for vm in $(az vm list --query "[].name" -o tsv); do
cpu=$(az monitor metrics list \
--resource-group prod \
--metric "Percentage CPU" \
--start-time "2023-09-01T00:00:00Z" \
--end-time "2023-10-01T00:00:00Z" \
--interval PT1H \
--query "[0].timeseries[0].data | sort_by(@, &average) | last(@).average" \
-o tsv)
if (( $(echo "$cpu < 20" | bc -l) )); then
echo "VM $vm has average CPU < 20%: consider stopping"
gh pr comment --body "⚠️ Low CPU on $vm: $cpu%" --pr $PR_NUMBER
fi
done
A silent failure: bc -l is not installed in the GitHub Actions runner. Use apt-get install bc in the workflow.
Optimizing Azure cloud costs is a continuous, multi-step workflow: identify cost drivers with the Cost Management API, rightsize compute using monitoring data, optimize storage with lifecycle policies, enforce configurations via Azure Policies, and monitor anomalies with alerts. Each step uses real, existing tools and commands. The patterns—fetching, aggregating, filtering, automating, and validating—work across any Azure environment, regardless of the underlying platform or tooling.
This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy