The aim of this post is to present an approach to cleaning up orphaned resources in Azure to prevent unnecessary costs and improve the maintainability of the cloud environment. Azure Automation lets you streamline and orchestrate repetitive tasks across your Azure environment. Whether you’re cleaning up orphaned resources, rotating secrets, or enforcing compliance, Automation gives you the tools to run scripts and schedule workflows using PowerShell, Python, or interactive notebooks.
The tagging and cleanup process
I have selected the usual culprits — public IPs, unattached disks, and NICs — to demonstrate the process. The process itself has been divided into two Azure Automation PowerShell notebooks. The first script looks for orphaned resources and tags them for deletion. The second script actually deletes the tagged resources. I have also allowed for the scenario where a team might want to retain an orphaned resource by introducing a tag called “CleanupExemption”. Splitting the process into two notebooks provides a grace period, giving you time to review and possibly exclude some of the orphaned resources before they are permanently deleted.
Prerequisites
The following script will create the Azure Automation account required to test and schedule the runbooks. The script assigns a system assigned managed identity for the Automation account. This identity will be used to access the resources needed within the runbooks.
# Set variables for the Automation account
$ResourceGroupName="<rg>"
$AutomationAccountName="<automation-account>"
$Location="<location>"
New-AzResourceGroup -Name $ResourceGroupName -Location $Location
# Create the Automation account with a system-assigned identity
$AutomationAccount = New-AzAutomationAccount `
-ResourceGroupName $ResourceGroupName `
-Name $AutomationAccountName `
-Location $Location `
-AssignSystemIdentity
Azure Automation Account Managed Identity
The system assigned managed identity needs to be assigned appropriate RBAC roles at the target scope level.
$TargetSubscription = "<target subscription>"
$SubscriptionId = (Get-AzSubscription -SubscriptionName $TargetSubscription).SubscriptionId
$PrincipalId = $AutomationAccount.Identity.PrincipalId
New-AzRoleAssignment -ObjectId $PrincipalId -RoleDefinitionName "Contributor" -Scope "/subscriptions/$SubscriptionId"
Implementation
Here is the code for the tagging notebook. The script accepts three parameters — target subscription id, the resource type to look for, and the name of the tag. This script can be run from a “master” notebook, which will orchestrate the entire cleanup process. Based on the resource type supplied, the script uses specific conditions to identify orphaned resources. Expanding this script to include more resource types will involve modifying the switch statement to specify the condition that will locate the orphaned resource of that particular type.
Once the orphaned resources of a type are identified, the script looks for the CleanupExemption tag. If found, that resource will be skipped. The rest of the resources are tagged with the default MarkedForDeletion tag with today’s date. Logging of this process will be covered in a future post.
param(
[string]$SubscriptionId,
[string]$ResourceType = 'Microsoft.Network/publicIPAddresses',
[string]$TagKey = 'MarkedForCleanup'
)
# Authenticate to Azure
try {
Connect-AzAccount -Identity
}
catch {
Write-Error "Failed to connect."
return
}
# Set the context to the provided subscription ID
# Make it explicit which subscription is being targeted by the following commands
Set-AzContext -SubscriptionId $SubscriptionId
# Define the current date for the tag value
$currentTime = Get-Date
$logEntries = @()
Write-Output "Starting scan for orphaned resources of type: $ResourceType"
switch ($ResourceType) {
'Microsoft.Network/publicIPAddresses' {
$resources = Get-AzPublicIpAddress | Where-Object { $_.IpConfiguration -eq $null }
$description = "Public IP"
}
'Microsoft.Compute/disks' {
$resources = Get-AzDisk | Where-Object { $_.ManagedBy -eq $null }
$description = "Disk"
}
'Microsoft.Network/networkInterfaces' {
$resources = Get-AzNetworkInterface | Where-Object { $_.VirtualMachine -eq $null }
$description = "NIC"
}
default {
Write-Error "Unsupported resource type: $ResourceType"
exit
}
}
foreach ($orphanedResource in $resources) {
$resource = Get-AzResource -ResourceType $ResourceType -ResourceGroupName $orphanedResource.ResourceGroupName -ResourceName $orphanedResource.Name
# Check for the exemption tag first
if ($resource.Tags.ContainsKey('CleanupExemption') -and ($resource.Tags.CleanupExemption -eq 'true')) {
Write-Output "Skipping $($resource.Name) due to CleanupExemption tag."
continue
}
# Check if the resource has already been tagged
if (-not $resource.Tags.ContainsKey($TagKey)) {
$tags = $resource.Tags
$tags.Add($TagKey, $currentTime.ToString("yyyy-MM-dd"))
# Apply the tag
try {
Set-AzResource -ResourceGroupName $resource.ResourceGroupName -ResourceName $resource.Name -ResourceType $ResourceType -Tag $tags -Force -ErrorAction Stop
# Create the log entry object
$logEntry = @{
ResourceID = $resource.Id
ResourceName = $resource.Name
ResourceType = $resource.ResourceType
SubscriptionID = $resource.Id.Split('/')[2]
ResourceGroupName = $resource.ResourceGroupName
CleanupAction = "TaggedForCleanup"
RunbookName = "TagForCleanup"
ProjectTagValue = $resource.Tags.Project
OwnerEmail = $resource.Tags.OwnerEmail
TimeGenerated = (Get-Date).ToString("o")
}
$logEntries += $logEntry
Write-Output "Successfully tagged orphaned ${description}: $($resource.Name)."
}
catch {
Write-Error "Failed to tag ${description}: $($resource.Name). Error: $_"
}
}
}
Write-Output "Finished tagging run for resource type: $ResourceType."
Testing the notebook
You can test the notebooks in a draft state once they have been uploaded to the automation account. Publishing the notebook and turning on diagnostic settings for the Automation Account will ensure the job details and the data written using the Write-Output and Write-Error statements will be sent to the chosen log analytics workspace.
Here is the script for the resource cleanup notebook. The default grace period is 7 days. You pass the target subscription id and resource type. The script looks for resources of that type and with a MarkedForDeletion tag value that is less than the threshold date (today – grace period days). This script also honours the CleanupExemption tag. All the resources found will then be deleted unless they have the exemption tag.
param(
[string]$SubscriptionId,
[string]$ResourceType = 'Microsoft.Network/publicIPAddresses',
[string]$TagKey = 'MarkedForCleanup',
[int]$GracePeriodDays = 7
)
# Authenticate to Azure
try {
Connect-AzAccount -Identity
}
catch {
Write-Error "Failed to connect."
return
}
# Set the context to the provided subscription ID
# Make it explicit which subscription is being targeted by the following commands
Set-AzContext -SubscriptionId $SubscriptionId
# Define the threshold date for deletion
$thresholdDate = (Get-Date).AddDays(-$GracePeriodDays)
$logEntries = @()
Write-Output "Starting deletion of $ResourceType resources tagged for cleanup."
$resourcesForDeletion = Get-AzResource -ResourceType $ResourceType | Where-Object {
$_.Tags.ContainsKey($TagKey) -and ([datetime]$_.Tags.MarkedForCleanup -lt $thresholdDate)
}
Write-Output "Found $($resourcesForDeletion.Count) $ResourceType resources marked for deletion."
foreach ($resource in $resourcesForDeletion) {
# Check for the cleanupExemption tag before deletion
if ($resource.Tags.ContainsKey('CleanupExemption') -and ($resource.Tags.CleanupExemption -eq 'true')) {
Write-Output "Skipping deletion of $($resource.Name) due to CleanupExemption tag."
continue
}
try {
Remove-AzResource -ResourceGroupName $resource.ResourceGroupName -ResourceName $resource.Name -ResourceType $ResourceType -Force
# Create the log entry object for deletion
$logEntry = @{
ResourceID = $resource.Id
ResourceName = $resource.Name
ResourceType = $resource.ResourceType
SubscriptionID = $resource.Id.Split('/')[2]
ResourceGroupName = $resource.ResourceGroupName
CleanupAction = 'Deleted'
RunbookName = "CleanupResource"
ProjectTagValue = $resource.Tags.Project
OwnerEmail = $resource.Tags.OwnerEmail
TimeGenerated = (Get-Date).ToString("o")
}
$logEntries += $logEntry
Write-Output "Successfully deleted ${ResourceType}: $($resource.Name)."
}
catch {
Write-Output "Failed to delete $(ResourceType): $($resource.Name). Error: $_"
}
}
Write-Output "Deletion script finished."
In both scripts, I’m collecting log entries. In a future post, I will demonstrate how to send these logs to a custom log analytics workspace table using the new Log Ingestion API and data collection rules. As you can see, I’m storing the project name and the owner’s email address in these logs.
A future enhancement would be to send an email to the owner informing them about the tagging and future deletion so that they can take any necessary action. This is assuming that those tags were enforced during resource creation using Azure Policy. If tagging information isn’t available, we can create a dashboard from the logged data to track the cleanup process.