cost-optimization

The Ultimate Guide to Finding and Safely Deleting Azure Orphaned Resources

January 22, 2024 Nathaniel Fishel 12 min

Introduction: The Hidden Cost of Orphaned Resources

Every Azure environment has them—orphaned resources silently draining your budget. Unattached disks from deleted VMs. Public IPs that haven’t been used in months. Network interfaces connected to nothing. Snapshots from projects that ended years ago.

In our investigations, we’ve found that orphaned resources typically account for 15-25% of Azure waste in mature environments. That’s thousands of dollars monthly going to absolutely nothing.

This guide provides a complete, technical approach to finding and safely removing these orphaned resources. Every script has been battle-tested in production environments managing millions in Azure spend.

What Are Orphaned Resources?

Orphaned resources are Azure resources that exist in your subscription but serve no functional purpose. They’re not attached to any active resource, not serving any traffic, and not providing any value—yet they’re still billing you.

Common Types of Orphaned Resources

Resource TypeAverage Monthly CostHow They Get Orphaned
Managed Disks$50-500 per diskVM deleted without deleting disks
Public IP Addresses$3.65-5.00 eachLoad balancer or VM removed
Network Interfaces$0 (but clutter)VM deleted, NIC remains
Snapshots$5-50 per snapshotForgotten after backup restore
Network Security Groups$0 (but risky)Subnet or NIC deleted
Route Tables$0 (but confusing)VNet restructuring
Load Balancers$25+ eachApplication decommissioned
Application Gateways$200+ eachApp migration to different solution

Real-World Impact

Case Study: A SaaS company discovered:

  • 234 unattached Premium SSD disks (P30/P40)
  • Total monthly cost: $42,000
  • How long orphaned: 8-18 months
  • Total waste before discovery: ~$500,000

Part 1: Finding Orphaned Disks

Unattached managed disks are the most expensive orphaned resource. Here’s a comprehensive approach to finding them.

Basic Detection Script

# Connect to Azure
Connect-AzAccount

# Get all subscriptions
$subscriptions = Get-AzSubscription

$allOrphanedDisks = @()

foreach ($subscription in $subscriptions) {
    Write-Host "Checking subscription: $($subscription.Name)" -ForegroundColor Cyan
    Set-AzContext -SubscriptionId $subscription.Id
    
    # Find all unattached managed disks
    $orphanedDisks = Get-AzDisk | Where-Object {$_.ManagedBy -eq $null}
    
    foreach ($disk in $orphanedDisks) {
        # Calculate monthly cost based on disk type and size
        $monthlyCost = switch($disk.Sku.Name) {
            'Premium_LRS' { 
                # Premium SSD pricing (approximate)
                switch([int]$disk.DiskSizeGB) {
                    {$_ -le 32} { 5.28 }      # P4
                    {$_ -le 64} { 10.21 }     # P6
                    {$_ -le 128} { 19.71 }    # P10
                    {$_ -le 256} { 38.40 }    # P15
                    {$_ -le 512} { 73.22 }    # P20
                    {$_ -le 1024} { 135.17 }  # P30
                    {$_ -le 2048} { 259.05 }  # P40
                    {$_ -le 4096} { 494.02 }  # P50
                    default { 494.02 }
                }
            }
            'StandardSSD_LRS' { 
                # Standard SSD pricing
                $disk.DiskSizeGB * 0.075 
            }
            'Standard_LRS' { 
                # Standard HDD pricing
                $disk.DiskSizeGB * 0.045 
            }
            'UltraSSD_LRS' {
                # Ultra disk (complex pricing, using estimate)
                $disk.DiskSizeGB * 0.32
            }
            default { $disk.DiskSizeGB * 0.05 }
        }
        
        $diskInfo = [PSCustomObject]@{
            Subscription = $subscription.Name
            DiskName = $disk.Name
            ResourceGroup = $disk.ResourceGroupName
            Location = $disk.Location
            DiskSizeGB = $disk.DiskSizeGB
            DiskType = $disk.Sku.Name
            CreationTime = $disk.TimeCreated
            DaysSinceCreation = [math]::Round((New-TimeSpan -Start $disk.TimeCreated -End (Get-Date)).TotalDays)
            LastModified = $disk.LastModifiedTime
            EstimatedMonthlyCost = [math]::Round($monthlyCost, 2)
            Tags = $disk.Tags | ConvertTo-Json -Compress
        }
        
        $allOrphanedDisks += $diskInfo
    }
}

# Generate report
$totalMonthlyCost = ($allOrphanedDisks | Measure-Object -Property EstimatedMonthlyCost -Sum).Sum
$totalAnnualCost = $totalMonthlyCost * 12

Write-Host "`n========== ORPHANED DISK REPORT ==========" -ForegroundColor Yellow
Write-Host "Total Orphaned Disks Found: $($allOrphanedDisks.Count)"
Write-Host "Total Monthly Cost: `$$([math]::Round($totalMonthlyCost, 2))"
Write-Host "Total Annual Cost: `$$([math]::Round($totalAnnualCost, 2))"
Write-Host "==========================================" -ForegroundColor Yellow

# Export to CSV for review
$allOrphanedDisks | Export-Csv -Path "OrphanedDisks_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation

# Show top 10 most expensive
Write-Host "`nTop 10 Most Expensive Orphaned Disks:" -ForegroundColor Cyan
$allOrphanedDisks | Sort-Object EstimatedMonthlyCost -Descending | Select-Object -First 10 | Format-Table DiskName, DiskSizeGB, DiskType, EstimatedMonthlyCost

Advanced Safety Validation

Before deleting any disk, validate it’s truly safe:

function Test-DiskSafeToDelete {
    param(
        [Parameter(Mandatory=$true)]
        [string]$DiskName,
        [Parameter(Mandatory=$true)]
        [string]$ResourceGroupName
    )
    
    $disk = Get-AzDisk -ResourceGroupName $ResourceGroupName -DiskName $DiskName
    $safetyChecks = @()
    
    # Check 1: Age of disk
    $age = (New-TimeSpan -Start $disk.TimeCreated -End (Get-Date)).TotalDays
    $safetyChecks += [PSCustomObject]@{
        Check = "Age Check"
        Result = if($age -gt 30) {"PASS"} else {"FAIL"}
        Details = "Disk age: $([math]::Round($age)) days (Safe if > 30 days)"
    }
    
    # Check 2: Recent modifications
    $lastModified = (New-TimeSpan -Start $disk.LastModifiedTime -End (Get-Date)).TotalDays
    $safetyChecks += [PSCustomObject]@{
        Check = "Last Modified"
        Result = if($lastModified -gt 7) {"PASS"} else {"FAIL"}
        Details = "Last modified: $([math]::Round($lastModified)) days ago (Safe if > 7 days)"
    }
    
    # Check 3: Check for "Do Not Delete" tags
    $protectedTags = @("DoNotDelete", "Protected", "Keep", "Production", "Critical")
    $hasProtectedTag = $false
    foreach ($tag in $protectedTags) {
        if ($disk.Tags.Keys -contains $tag -or $disk.Tags.Values -contains $tag) {
            $hasProtectedTag = $true
            break
        }
    }
    $safetyChecks += [PSCustomObject]@{
        Check = "Protected Tags"
        Result = if(!$hasProtectedTag) {"PASS"} else {"FAIL"}
        Details = "Protected tags found: $hasProtectedTag"
    }
    
    # Check 4: Snapshot existence
    $snapshots = Get-AzSnapshot -ResourceGroupName $ResourceGroupName | Where-Object {$_.CreationData.SourceResourceId -eq $disk.Id}
    $safetyChecks += [PSCustomObject]@{
        Check = "Recent Snapshots"
        Result = if($snapshots.Count -eq 0) {"PASS"} else {"WARNING"}
        Details = "Snapshots found: $($snapshots.Count)"
    }
    
    # Check 5: Disk encryption
    $isEncrypted = $disk.Encryption.Type -ne "EncryptionAtRestWithPlatformKey"
    $safetyChecks += [PSCustomObject]@{
        Check = "Encryption"
        Result = if(!$isEncrypted) {"PASS"} else {"WARNING"}
        Details = "Custom encryption: $isEncrypted (May contain sensitive data)"
    }
    
    # Display results
    Write-Host "`nSafety Validation for Disk: $DiskName" -ForegroundColor Yellow
    Write-Host "Resource Group: $ResourceGroupName" -ForegroundColor Yellow
    $safetyChecks | Format-Table Check, Result, Details -AutoSize
    
    # Overall recommendation
    $failures = ($safetyChecks | Where-Object {$_.Result -eq "FAIL"}).Count
    $warnings = ($safetyChecks | Where-Object {$_.Result -eq "WARNING"}).Count
    
    if ($failures -eq 0 -and $warnings -eq 0) {
        Write-Host "✓ SAFE TO DELETE" -ForegroundColor Green
        return $true
    } elseif ($failures -eq 0) {
        Write-Host "⚠ PROBABLY SAFE (Review warnings)" -ForegroundColor Yellow
        return $true
    } else {
        Write-Host "✗ NOT SAFE TO DELETE" -ForegroundColor Red
        return $false
    }
}

# Example usage
Test-DiskSafeToDelete -DiskName "VM-PROD-DISK-01" -ResourceGroupName "RG-PRODUCTION"

Bulk Deletion Script (With Safety)

function Remove-OrphanedDisksWithValidation {
    param(
        [Parameter(Mandatory=$true)]
        [string]$CsvPath,
        [switch]$WhatIf = $true  # Default to WhatIf mode for safety
    )
    
    $disks = Import-Csv -Path $CsvPath
    $deletionLog = @()
    $totalSavings = 0
    
    foreach ($disk in $disks) {
        Write-Host "`nProcessing: $($disk.DiskName)" -ForegroundColor Cyan
        
        # Run safety validation
        $isSafe = Test-DiskSafeToDelete -DiskName $disk.DiskName -ResourceGroupName $disk.ResourceGroup
        
        if ($isSafe) {
            if ($WhatIf) {
                Write-Host "WHATIF: Would delete disk $($disk.DiskName) - Saving `$$($disk.EstimatedMonthlyCost)/month" -ForegroundColor Yellow
            } else {
                try {
                    Remove-AzDisk -ResourceGroupName $disk.ResourceGroup -DiskName $disk.DiskName -Force
                    Write-Host "✓ Deleted disk $($disk.DiskName) - Saved `$$($disk.EstimatedMonthlyCost)/month" -ForegroundColor Green
                    $deletionLog += [PSCustomObject]@{
                        Timestamp = Get-Date
                        DiskName = $disk.DiskName
                        ResourceGroup = $disk.ResourceGroup
                        Status = "Deleted"
                        MonthlySavings = $disk.EstimatedMonthlyCost
                    }
                    $totalSavings += $disk.EstimatedMonthlyCost
                } catch {
                    Write-Host "✗ Failed to delete $($disk.DiskName): $_" -ForegroundColor Red
                    $deletionLog += [PSCustomObject]@{
                        Timestamp = Get-Date
                        DiskName = $disk.DiskName
                        ResourceGroup = $disk.ResourceGroup
                        Status = "Failed"
                        Error = $_.Exception.Message
                    }
                }
            }
        } else {
            Write-Host "⊘ Skipping $($disk.DiskName) - Failed safety validation" -ForegroundColor Magenta
        }
    }
    
    # Save deletion log
    $logPath = "DiskDeletionLog_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
    $deletionLog | Export-Csv -Path $logPath -NoTypeInformation
    
    Write-Host "`n========== DELETION SUMMARY ==========" -ForegroundColor Green
    Write-Host "Total Monthly Savings: `$$totalSavings"
    Write-Host "Annual Savings: `$$($totalSavings * 12)"
    Write-Host "Deletion log saved to: $logPath"
    Write-Host "======================================" -ForegroundColor Green
}

# Run in WhatIf mode first
Remove-OrphanedDisksWithValidation -CsvPath "OrphanedDisks_20240122.csv" -WhatIf

# After review, run actual deletion
# Remove-OrphanedDisksWithValidation -CsvPath "OrphanedDisks_20240122.csv" -WhatIf:$false

Part 2: Finding Other Orphaned Resources

Orphaned Public IPs

function Find-OrphanedPublicIPs {
    $orphanedIPs = @()
    $subscriptions = Get-AzSubscription
    
    foreach ($subscription in $subscriptions) {
        Set-AzContext -SubscriptionId $subscription.Id
        
        $publicIPs = Get-AzPublicIpAddress | Where-Object {
            $_.IpConfiguration -eq $null -and 
            $_.NatGateway -eq $null -and
            $_.PublicIpPrefix -eq $null
        }
        
        foreach ($ip in $publicIPs) {
            # Check if it's associated with any resource
            $isOrphaned = $true
            
            # Check for Load Balancer association
            if ($ip.IpConfiguration) { $isOrphaned = $false }
            
            # Check for VPN Gateway association
            if ($ip.Id -match "vpn" -or $ip.Id -match "gateway") { 
                # Extra caution for VPN resources
                $isOrphaned = $false 
            }
            
            if ($isOrphaned) {
                $orphanedIPs += [PSCustomObject]@{
                    Subscription = $subscription.Name
                    Name = $ip.Name
                    ResourceGroup = $ip.ResourceGroupName
                    Location = $ip.Location
                    IPAddress = $ip.IpAddress
                    SKU = $ip.Sku.Name
                    AllocationMethod = $ip.PublicIpAllocationMethod
                    CreatedDate = $ip.Tag['CreatedDate']  # If tagged
                    MonthlyCost = if($ip.Sku.Name -eq 'Standard') { 5.00 } else { 3.65 }
                }
            }
        }
    }
    
    # Generate report
    $totalMonthlyCost = ($orphanedIPs | Measure-Object -Property MonthlyCost -Sum).Sum
    
    Write-Host "`nOrphaned Public IPs Found: $($orphanedIPs.Count)" -ForegroundColor Yellow
    Write-Host "Total Monthly Cost: `$$totalMonthlyCost" -ForegroundColor Yellow
    
    $orphanedIPs | Export-Csv -Path "OrphanedPublicIPs_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
    
    return $orphanedIPs
}

# Find and display orphaned IPs
$orphanedIPs = Find-OrphanedPublicIPs
$orphanedIPs | Format-Table Name, ResourceGroup, IPAddress, MonthlyCost

Orphaned Network Interfaces

function Find-OrphanedNetworkInterfaces {
    $orphanedNICs = @()
    
    $nics = Get-AzNetworkInterface | Where-Object {
        $_.VirtualMachine -eq $null -and
        $_.PrivateEndpoint -eq $null -and
        $_.NetworkSecurityGroup -eq $null
    }
    
    foreach ($nic in $nics) {
        # Additional validation
        $lastActivity = Get-AzLog -ResourceId $nic.Id -StartTime (Get-Date).AddDays(-30) -ErrorAction SilentlyContinue
        
        $orphanedNICs += [PSCustomObject]@{
            Name = $nic.Name
            ResourceGroup = $nic.ResourceGroupName
            Location = $nic.Location
            PrivateIP = $nic.IpConfigurations[0].PrivateIpAddress
            Subnet = $nic.IpConfigurations[0].Subnet.Id.Split('/')[-1]
            VNet = $nic.IpConfigurations[0].Subnet.Id.Split('/')[8]
            LastActivity = if($lastActivity) { $lastActivity[0].EventTimestamp } else { "Unknown" }
            DaysSinceActivity = if($lastActivity) { 
                [math]::Round((New-TimeSpan -Start $lastActivity[0].EventTimestamp -End (Get-Date)).TotalDays) 
            } else { 999 }
        }
    }
    
    Write-Host "`nOrphaned Network Interfaces Found: $($orphanedNICs.Count)" -ForegroundColor Yellow
    
    # NICs don't have direct cost but create clutter and confusion
    $orphanedNICs | Where-Object {$_.DaysSinceActivity -gt 30} | 
        Export-Csv -Path "OrphanedNICs_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
    
    return $orphanedNICs
}

$orphanedNICs = Find-OrphanedNetworkInterfaces
$orphanedNICs | Where-Object {$_.DaysSinceActivity -gt 30} | Format-Table Name, ResourceGroup, PrivateIP, DaysSinceActivity

Orphaned Snapshots

function Find-OrphanedSnapshots {
    $orphanedSnapshots = @()
    $allSnapshots = Get-AzSnapshot
    
    foreach ($snapshot in $allSnapshots) {
        $isOrphaned = $false
        
        # Check if source disk still exists
        if ($snapshot.CreationData.SourceResourceId) {
            try {
                $sourceDisk = Get-AzResource -ResourceId $snapshot.CreationData.SourceResourceId -ErrorAction Stop
            } catch {
                # Source disk no longer exists
                $isOrphaned = $true
            }
        }
        
        # Calculate age
        $ageInDays = (New-TimeSpan -Start $snapshot.TimeCreated -End (Get-Date)).TotalDays
        
        # Consider snapshots older than 90 days as candidates
        if ($ageInDays -gt 90 -or $isOrphaned) {
            # Calculate approximate monthly cost (snapshot pricing is complex)
            $sizeGB = if($snapshot.DiskSizeGB) { $snapshot.DiskSizeGB } else { 100 }  # Default estimate
            $monthlyCost = $sizeGB * 0.05  # Approximate snapshot storage cost
            
            $orphanedSnapshots += [PSCustomObject]@{
                Name = $snapshot.Name
                ResourceGroup = $snapshot.ResourceGroupName
                Location = $snapshot.Location
                SourceDisk = $snapshot.CreationData.SourceResourceId.Split('/')[-1]
                CreationTime = $snapshot.TimeCreated
                AgeInDays = [math]::Round($ageInDays)
                SizeGB = $sizeGB
                SourceExists = !$isOrphaned
                EstimatedMonthlyCost = [math]::Round($monthlyCost, 2)
            }
        }
    }
    
    $totalMonthlyCost = ($orphanedSnapshots | Measure-Object -Property EstimatedMonthlyCost -Sum).Sum
    
    Write-Host "`nOrphaned/Old Snapshots Found: $($orphanedSnapshots.Count)" -ForegroundColor Yellow
    Write-Host "Total Monthly Cost: `$$totalMonthlyCost" -ForegroundColor Yellow
    
    $orphanedSnapshots | Export-Csv -Path "OrphanedSnapshots_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
    
    return $orphanedSnapshots
}

$snapshots = Find-OrphanedSnapshots
$snapshots | Sort-Object AgeInDays -Descending | Select-Object -First 20 | Format-Table Name, AgeInDays, SizeGB, EstimatedMonthlyCost

Part 3: Automation Strategy

Azure Automation Runbook for Continuous Cleanup

Create an Azure Automation runbook that runs weekly:

# Azure Automation Runbook: Clean-OrphanedResources.ps1

param(
    [Parameter(Mandatory=$false)]
    [bool]$AutoDelete = $false,  # Set to true only after testing
    
    [Parameter(Mandatory=$false)]
    [string]$EmailRecipient = "sre-team@company.com"
)

# Authenticate using Managed Identity
Connect-AzAccount -Identity

$report = @{
    RunDate = Get-Date
    DisksFound = 0
    DisksSavings = 0
    IPsFound = 0
    IPsSavings = 0
    NICsFound = 0
    SnapshotsFound = 0
    SnapshotsSavings = 0
    TotalSavings = 0
    ResourcesDeleted = @()
}

# Find orphaned disks
Write-Output "Scanning for orphaned disks..."
$orphanedDisks = Get-AzDisk | Where-Object {$_.ManagedBy -eq $null}
$report.DisksFound = $orphanedDisks.Count

foreach ($disk in $orphanedDisks) {
    $age = (New-TimeSpan -Start $disk.TimeCreated -End (Get-Date)).TotalDays
    
    if ($age -gt 30) {  # Only consider disks older than 30 days
        # Calculate cost
        $monthlyCost = switch($disk.Sku.Name) {
            'Premium_LRS' { $disk.DiskSizeGB * 0.135 }
            'StandardSSD_LRS' { $disk.DiskSizeGB * 0.075 }
            'Standard_LRS' { $disk.DiskSizeGB * 0.045 }
            default { $disk.DiskSizeGB * 0.05 }
        }
        
        $report.DisksSavings += $monthlyCost
        
        if ($AutoDelete) {
            try {
                Remove-AzDisk -ResourceGroupName $disk.ResourceGroupName -DiskName $disk.Name -Force
                $report.ResourcesDeleted += "Disk: $($disk.Name)"
                Write-Output "Deleted disk: $($disk.Name)"
            } catch {
                Write-Error "Failed to delete disk $($disk.Name): $_"
            }
        }
    }
}

# Find orphaned Public IPs
Write-Output "Scanning for orphaned public IPs..."
$orphanedIPs = Get-AzPublicIpAddress | Where-Object {$_.IpConfiguration -eq $null}
$report.IPsFound = $orphanedIPs.Count
$report.IPsSavings = $orphanedIPs.Count * 3.65  # Basic SKU cost

if ($AutoDelete) {
    foreach ($ip in $orphanedIPs) {
        try {
            Remove-AzPublicIpAddress -ResourceGroupName $ip.ResourceGroupName -Name $ip.Name -Force
            $report.ResourcesDeleted += "PublicIP: $($ip.Name)"
            Write-Output "Deleted public IP: $($ip.Name)"
        } catch {
            Write-Error "Failed to delete IP $($ip.Name): $_"
        }
    }
}

# Find orphaned NICs
Write-Output "Scanning for orphaned network interfaces..."
$orphanedNICs = Get-AzNetworkInterface | Where-Object {$_.VirtualMachine -eq $null}
$report.NICsFound = $orphanedNICs.Count

if ($AutoDelete) {
    foreach ($nic in $orphanedNICs) {
        # Extra safety check - ensure NIC has been orphaned for at least 7 days
        $logs = Get-AzLog -ResourceId $nic.Id -StartTime (Get-Date).AddDays(-7) -ErrorAction SilentlyContinue
        
        if (-not $logs -or $logs.Count -eq 0) {
            try {
                Remove-AzNetworkInterface -ResourceGroupName $nic.ResourceGroupName -Name $nic.Name -Force
                $report.ResourcesDeleted += "NIC: $($nic.Name)"
                Write-Output "Deleted NIC: $($nic.Name)"
            } catch {
                Write-Error "Failed to delete NIC $($nic.Name): $_"
            }
        }
    }
}

# Calculate total savings
$report.TotalSavings = $report.DisksSavings + $report.IPsSavings + $report.SnapshotsSavings

# Generate email report
$emailBody = @"
<html>
<head>
    <style>
        table { border-collapse: collapse; width: 100%; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        th { background-color: #4CAF50; color: white; }
        .savings { color: green; font-weight: bold; }
    </style>
</head>
<body>
    <h2>Azure Orphaned Resources Report</h2>
    <p>Run Date: $($report.RunDate)</p>
    
    <table>
        <tr>
            <th>Resource Type</th>
            <th>Count Found</th>
            <th>Monthly Savings</th>
            <th>Action Taken</th>
        </tr>
        <tr>
            <td>Managed Disks</td>
            <td>$($report.DisksFound)</td>
            <td class="savings">`$$([math]::Round($report.DisksSavings, 2))</td>
            <td>$(if($AutoDelete) {"Deleted"} else {"Report Only"})</td>
        </tr>
        <tr>
            <td>Public IPs</td>
            <td>$($report.IPsFound)</td>
            <td class="savings">`$$([math]::Round($report.IPsSavings, 2))</td>
            <td>$(if($AutoDelete) {"Deleted"} else {"Report Only"})</td>
        </tr>
        <tr>
            <td>Network Interfaces</td>
            <td>$($report.NICsFound)</td>
            <td>N/A</td>
            <td>$(if($AutoDelete) {"Deleted"} else {"Report Only"})</td>
        </tr>
    </table>
    
    <h3>Total Monthly Savings: <span class="savings">`$$([math]::Round($report.TotalSavings, 2))</span></h3>
    <h3>Annual Savings: <span class="savings">`$$([math]::Round($report.TotalSavings * 12, 2))</span></h3>
    
    $(if($report.ResourcesDeleted.Count -gt 0) {
        "<h3>Resources Deleted:</h3><ul>"
        $report.ResourcesDeleted | ForEach-Object { "<li>$_</li>" }
        "</ul>"
    })
</body>
</html>
"@

# Send email report
Send-MailMessage `
    -To $EmailRecipient `
    -From "azure-automation@company.com" `
    -Subject "Azure Orphaned Resources Report - $($report.RunDate.ToString('yyyy-MM-dd'))" `
    -Body $emailBody `
    -BodyAsHtml `
    -SmtpServer "smtp.company.com"

Write-Output "Report sent to $EmailRecipient"
Write-Output "Total potential monthly savings: `$$($report.TotalSavings)"

Setting Up the Automation

  1. Create Automation Account:
# Create automation account
New-AzAutomationAccount `
    -ResourceGroupName "RG-Automation" `
    -Name "AA-CostOptimization" `
    -Location "East US" `
    -Plan "Basic"

# Enable Managed Identity
Set-AzAutomationAccount `
    -ResourceGroupName "RG-Automation" `
    -Name "AA-CostOptimization" `
    -AssignSystemIdentity
  1. Grant Permissions:
# Get the Managed Identity principal ID
$automation = Get-AzAutomationAccount -ResourceGroupName "RG-Automation" -Name "AA-CostOptimization"
$principalId = $automation.Identity.PrincipalId

# Assign Reader role (for reporting only)
New-AzRoleAssignment `
    -ObjectId $principalId `
    -RoleDefinitionName "Reader" `
    -Scope "/subscriptions/$subscriptionId"

# For auto-deletion, assign Contributor on specific resource groups only
New-AzRoleAssignment `
    -ObjectId $principalId `
    -RoleDefinitionName "Contributor" `
    -Scope "/subscriptions/$subscriptionId/resourceGroups/RG-Dev"
  1. Schedule the Runbook:
# Create schedule
New-AzAutomationSchedule `
    -ResourceGroupName "RG-Automation" `
    -AutomationAccountName "AA-CostOptimization" `
    -Name "WeeklyOrphanedResourceCleanup" `
    -StartTime (Get-Date).AddDays(1) `

Get Your Free Cloud Cost-Cutting Checklist

Join the weekly 'Cloud Sleuth' briefing and get my 10-point checklist to find hidden cloud waste, delivered instantly to your inbox.