DEV Community

Cover image for Your Cloud Bill Has Ghosts 👻 Orphaned Disks, IPs, and NICs Are Haunting Your Azure Subscription
Suhas Mallesh
Suhas Mallesh

Posted on

Your Cloud Bill Has Ghosts 👻 Orphaned Disks, IPs, and NICs Are Haunting Your Azure Subscription

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🛡️ 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

⚡ 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
Enter fullscreen mode Exit fullscreen mode

If that returns results, you have ghosts. 👻

💡 Architect Pro Tips

  • Tag disks with their parent VM name. Add an AttachedTo tag 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 MarkedForDeletion with 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 -ASRReplica and starting with ms-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)