When you delete an Azure VM, its disks, public IPs, and NICs stick around and keep billing you. Here's how to find and exorcise these ghost resources with Terraform and Azure Resource Graph - and prevent them from spawning in the first place.
A company deleted 30 dev VMs last quarter. Great housekeeping, right? Except nobody deleted the 30 OS disks, 22 data disks, 18 public IPs, and 30 NICs left behind. Monthly ghost bill: $1,200. For resources doing absolutely nothing. 👻
Here's how orphaned resources silently drain your budget:
Orphaned Resource Typical Cost 30 VMs Worth
-----------------------------------------------------------------
Premium SSD P30 (1 TB) $122.88/month $3,686/month
Standard SSD E30 (1 TB) $76.80/month $2,304/month
Premium SSD P10 (128 GB) $19.71/month $591/month
Standard Public IP $3.65/month $65/month
Unattached NIC $0 (but security risk)
Orphaned NSG $0 (but misconfiguration risk)
Disks are the biggest offender. A single orphaned P30 Premium SSD costs $122/month. Multiply that across dozens of forgotten VMs and you've got a ghost army eating your budget alive.
The worst part? Azure does NOT auto-delete these when you delete a VM. You have to clean them up yourself. Let's hunt them down and build prevention into your infrastructure. 🔦
🔍 Step 1: Find the Ghosts (Azure Resource Graph Queries)
Before you can fix the problem, you need to see it. These Azure Resource Graph (KQL) queries expose every orphaned resource in your subscription.
Unattached Managed Disks (The #1 Money Waster)
# Find all unattached managed disks with size and cost tier
az graph query -q "
Resources
| where type =~ 'microsoft.compute/disks'
| where managedBy == ''
| where not(name endswith '-ASRReplica')
| where not(name startswith 'ms-asr-')
| extend diskSizeGB = tostring(properties.diskSizeGB)
| extend diskSku = tostring(sku.name)
| extend diskState = tostring(properties.diskState)
| project name, resourceGroup, subscriptionId, location,
diskSizeGB, diskSku, diskState,
tags
| order by diskSku desc, diskSizeGB desc
" --output table
Why this matters: Every disk in this list is billing you at its full provisioned size, even though no VM is using it. A 1 TB Premium SSD sitting orphaned costs the same as one actively serving traffic.
Unassociated Public IPs
# Find public IPs not linked to any resource
az graph query -q "
Resources
| where type =~ 'microsoft.network/publicipaddresses'
| where properties.ipConfiguration == ''
| where properties.natGateway == ''
| extend sku = tostring(sku.name)
| extend allocationMethod = tostring(properties.publicIPAllocationMethod)
| project name, resourceGroup, subscriptionId, location,
sku, allocationMethod, tags
| order by sku desc
" --output table
Why this matters: Standard SKU public IPs cost ~$3.65/month each whether associated or not. They're also a security surface: an unassociated public IP can be reassigned and potentially exploited.
Orphaned Network Interfaces with Public IPs
# Find NICs not attached to any VM that have public IPs
az graph query -q "
Resources
| where type =~ 'microsoft.network/networkinterfaces'
| where properties.virtualMachine == ''
| where isnotempty(properties.ipConfigurations[0].properties.publicIPAddress)
| project name, resourceGroup, subscriptionId, location,
publicIP = properties.ipConfigurations[0].properties.publicIPAddress.id
" --output table
Unused Network Security Groups
# Find NSGs not associated with any NIC or subnet
az graph query -q "
Resources
| where type =~ 'microsoft.network/networksecuritygroups'
| where isnull(properties.networkInterfaces) and isnull(properties.subnets)
| project name, resourceGroup, subscriptionId, location, tags
" --output table
Why this matters: Orphaned NSGs don't cost money directly, but they're a governance and security risk. An incorrect NSG accidentally associated with a subnet could expose resources or block traffic.
Empty Availability Sets
az graph query -q "
Resources
| where type =~ 'microsoft.compute/availabilitysets'
| where array_length(properties.virtualMachines) == 0
| project name, resourceGroup, subscriptionId, location
" --output table
Load Balancers Without Backends
az graph query -q "
Resources
| where type =~ 'microsoft.network/loadbalancers'
| where array_length(properties.backendAddressPools) == 0
or properties.backendAddressPools[0].properties.loadBalancerBackendAddresses == '[]'
| extend sku = tostring(sku.name)
| project name, resourceGroup, subscriptionId, location, sku
" --output table
🛡️ Step 2: Prevent Ghosts From Spawning (Terraform Provider Config)
The best cleanup is prevention. Configure the Terraform AzureRM provider to auto-delete associated resources when VMs are destroyed:
# providers.tf - The ghost prevention config
provider "azurerm" {
features {
virtual_machine {
# Delete OS disk when VM is destroyed via Terraform
delete_os_disk_on_deletion = true
# Gracefully shut down VM before deletion
graceful_shutdown = false
# Don't skip shutdown and force delete
skip_shutdown_and_force_delete = false
}
resource_group {
# Prevent deletion of resource groups that still contain resources
prevent_deletion_if_contains_resources = true
}
}
}
Critical note: delete_os_disk_on_deletion only works when the VM is destroyed through Terraform. If someone deletes a VM through the Azure Portal or CLI, the disk still becomes orphaned. That's why you need both prevention AND detection.
Data Disks: Manage Them Explicitly
For data disks, always define them as separate azurerm_managed_disk resources and attach them with azurerm_virtual_machine_data_disk_attachment. This way Terraform tracks the disk lifecycle independently:
# Explicit disk management prevents orphans
resource "azurerm_managed_disk" "app_data" {
name = "disk-app-data-dev"
location = azurerm_resource_group.dev.location
resource_group_name = azurerm_resource_group.dev.name
storage_account_type = "Premium_LRS"
create_option = "Empty"
disk_size_gb = 256
tags = {
Environment = "dev"
CostCenter = "CC-1042"
Owner = "team-backend"
Project = "api-platform"
AttachedTo = "vm-api-dev" # Track what this disk belongs to
ManagedBy = "terraform"
}
}
resource "azurerm_virtual_machine_data_disk_attachment" "app_data" {
managed_disk_id = azurerm_managed_disk.app_data.id
virtual_machine_id = azurerm_linux_virtual_machine.dev_api.id
lun = 0
caching = "ReadWrite"
}
When Terraform destroys the VM, it destroys the attachment. When it destroys the disk resource, the actual disk goes with it. No orphans. 🎯
🤖 Step 3: Automated Ghost Hunting (Scheduled Cleanup Alerts)
Deploy an Azure Automation Runbook that scans for orphaned resources weekly and sends alerts. Don't auto-delete: always alert first so teams can verify before cleanup.
# ghost-hunter/main.tf
resource "azurerm_automation_account" "ghost_hunter" {
name = "aa-orphan-resource-scanner"
location = azurerm_resource_group.automation.location
resource_group_name = azurerm_resource_group.automation.name
sku_name = "Basic"
identity {
type = "SystemAssigned"
}
tags = {
Environment = "shared"
CostCenter = "platform"
Owner = "team-platform"
Project = "cost-governance"
ManagedBy = "terraform"
}
}
# Grant read access to scan all resources
resource "azurerm_role_assignment" "ghost_hunter_reader" {
scope = data.azurerm_subscription.current.id
role_definition_name = "Reader"
principal_id = azurerm_automation_account.ghost_hunter.identity[0].principal_id
}
resource "azurerm_automation_runbook" "scan_orphans" {
name = "Scan-Orphaned-Resources"
location = azurerm_resource_group.automation.location
resource_group_name = azurerm_resource_group.automation.name
automation_account_name = azurerm_automation_account.ghost_hunter.name
log_verbose = false
log_progress = false
runbook_type = "PowerShell72"
content = <<-POWERSHELL
# Connect using Managed Identity
Disable-AzContextAutosave -Scope Process
$AzureContext = (Connect-AzAccount -Identity).context
Set-AzContext -SubscriptionName $AzureContext.Subscription -DefaultProfile $AzureContext
$report = @()
# 1. Find unattached managed disks
$orphanedDisks = Get-AzDisk | Where-Object {
$_.ManagedBy -eq $null -and
$_.Name -notlike "*-ASRReplica" -and
$_.Name -notlike "ms-asr-*"
}
foreach ($disk in $orphanedDisks) {
$monthlyCost = switch ($disk.Sku.Name) {
"Premium_LRS" { [math]::Round($disk.DiskSizeGB * 0.12, 2) }
"StandardSSD_LRS" { [math]::Round($disk.DiskSizeGB * 0.075, 2) }
"Standard_LRS" { [math]::Round($disk.DiskSizeGB * 0.04, 2) }
default { 0 }
}
$report += [PSCustomObject]@{
Type = "Orphaned Disk"
Name = $disk.Name
ResourceGroup = $disk.ResourceGroupName
Details = "$($disk.Sku.Name) - $($disk.DiskSizeGB) GB"
EstMonthlyCost = "$" + $monthlyCost
Age = ((Get-Date) - $disk.TimeCreated).Days.ToString() + " days"
}
}
# 2. Find unassociated public IPs
$orphanedIPs = Get-AzPublicIpAddress | Where-Object {
$_.IpConfiguration -eq $null -and
$_.NatGateway -eq $null
}
foreach ($ip in $orphanedIPs) {
$report += [PSCustomObject]@{
Type = "Orphaned Public IP"
Name = $ip.Name
ResourceGroup = $ip.ResourceGroupName
Details = "$($ip.Sku.Name) - $($ip.PublicIpAllocationMethod)"
EstMonthlyCost = "$3.65"
Age = "N/A"
}
}
# 3. Find orphaned NICs (not attached to any VM)
$orphanedNICs = Get-AzNetworkInterface | Where-Object {
$_.VirtualMachine -eq $null
}
foreach ($nic in $orphanedNICs) {
$hasPublicIP = $nic.IpConfigurations | Where-Object {
$_.PublicIpAddress -ne $null
}
$report += [PSCustomObject]@{
Type = "Orphaned NIC"
Name = $nic.Name
ResourceGroup = $nic.ResourceGroupName
Details = if ($hasPublicIP) { "Has Public IP attached" } else { "No Public IP" }
EstMonthlyCost = if ($hasPublicIP) { "$3.65" } else { "$0" }
Age = "N/A"
}
}
# Output the report
if ($report.Count -gt 0) {
$totalCost = ($report | ForEach-Object {
[decimal]($_.EstMonthlyCost -replace '\$', '')
} | Measure-Object -Sum).Sum
Write-Output "========================================="
Write-Output "ORPHANED RESOURCE REPORT"
Write-Output "========================================="
Write-Output "Total orphaned resources found: $($report.Count)"
Write-Output "Estimated monthly waste: $$totalCost"
Write-Output "========================================="
$report | Format-Table -AutoSize
} else {
Write-Output "No orphaned resources found. Your subscription is clean!"
}
POWERSHELL
tags = azurerm_automation_account.ghost_hunter.tags
}
# Run weekly on Monday mornings
resource "azurerm_automation_schedule" "weekly_scan" {
name = "weekly-orphan-scan"
resource_group_name = azurerm_resource_group.automation.name
automation_account_name = azurerm_automation_account.ghost_hunter.name
frequency = "Week"
interval = 1
timezone = "Eastern Standard Time"
start_time = "2026-02-23T09:00:00-05:00"
description = "Weekly scan for orphaned resources"
week_days = ["Monday"]
}
resource "azurerm_automation_job_schedule" "weekly_scan" {
resource_group_name = azurerm_resource_group.automation.name
automation_account_name = azurerm_automation_account.ghost_hunter.name
schedule_name = azurerm_automation_schedule.weekly_scan.name
runbook_name = azurerm_automation_runbook.scan_orphans.name
}
⚡ Quick Cleanup: The One-Liner Audit
Run this right now. Seriously. It takes 10 seconds:
# The "how much am I wasting" one-liner
az disk list --query "[?managedBy==null].{
Name:name,
SizeGB:diskSizeGb,
SKU:sku.name,
RG:resourceGroup
}" --output table
If that returns results, you have ghosts. 👻
💡 Architect Pro Tips
Tag disks with their parent VM name. Add an
AttachedTotag when creating disks. When the VM is gone but the disk remains, you can trace it back to the original owner for cleanup decisions.Use Azure Resource Graph Workbook. Microsoft's open-source Orphaned Resources Workbook scans 30+ categories across your subscriptions. Deploy it alongside this Terraform automation for a visual dashboard.
Don't auto-delete: tag first, delete later. The runbook above reports orphans. For actual cleanup, a best practice is to first tag resources as
MarkedForDeletionwith a date, wait 14 days for teams to object, then delete. This prevents accidental data loss.Snapshot before you delete. For orphaned disks that might contain important data, create an incremental snapshot ($0.05/GB for used data only) before deleting the full disk. A 1 TB Premium SSD at $122/month becomes a $5/month snapshot.
ASR replicas are not orphans. The queries above exclude disks ending in
-ASRReplicaand starting withms-asr-. These are Azure Site Recovery replicas and should NOT be deleted even though they appear unattached.Empty resource groups are free, but messy. They cost nothing, but hundreds of empty resource groups clutter your environment and make it harder to manage. Clean them up as part of your governance cycle.
📊 TL;DR
| Ghost Resource | Typical Monthly Cost | How to Find | How to Prevent |
|---|---|---|---|
| Orphaned Premium SSD (128 GB) | $19.71 | az disk list --query "[?managedBy==null]" |
delete_os_disk_on_deletion = true |
| Orphaned Premium SSD (1 TB) | $122.88 | Same query | Explicit azurerm_managed_disk resources |
| Unassociated Public IP | $3.65 | az network public-ip list --query "[?ipConfiguration==null]" |
Delete IPs in Terraform when VM is removed |
| Orphaned NIC | $0 (security risk) | az network nic list --query "[?virtualMachine==null]" |
Manage NICs as explicit Terraform resources |
| Empty Load Balancer | $18+/month (Standard) | Resource Graph query | Tear down full stack, not just VMs |
Bottom line: Run the audit queries above. If you find even 10 orphaned Premium SSDs at 128 GB, that's $197/month for nothing. Over a year, that's $2,364 in pure waste. Combine this with the tagging enforcement from Part 1, and you'll be able to trace every orphan back to its origin. 👻
Go run that az disk list one-liner. Count the ghosts. Every disk in that list is money you're lighting on fire every single month. 🔥
This is Part 4 of the "Save on Azure with Terraform" series. Next up: Right-Sizing Your VMs 📏. That Standard_D8s_v5 running at 12% CPU is a 4x overspend waiting to be fixed. 💬
Top comments (0)