Identify and clean up orphaned Azure Virtual Networks that fragment IP address space, clutter network inventories, and anchor paid resources like VPN Gateways.
A Virtual Network (VNet) with no subnets, or subnets with no connected resources, is operational dead weight. The VNet itself is free, but it reserves a chunk of your private IP address space, appears in every network inventory and topology view, and can anchor paid resources like VPN Gateways, ExpressRoute circuits, or Azure Firewall instances that continue to bill even when the network they sit in serves no workloads.
Orphaned VNets accumulate naturally as projects are decommissioned, migrations complete, or proof-of-concept environments are abandoned. Left unchecked, they fragment your IP address plan, confuse network topology diagrams, and create stale peering connections that add latency to route lookups. This article walks through how to detect orphaned VNets, evaluate their dependencies, and safely remove them using a structured workflow that works at scale across MSP-managed tenants and enterprise subscriptions alike.
Key Takeaways at a Glance:
Orphaned VNets impose real operational costs even though Azure does not bill for the VNet resource itself. The overhead compounds as your Azure estate grows:
Orphaned VNets present in two distinct patterns, and the remediation path differs for each:
Important distinction: A VNet with a GatewaySubnet that contains an active VPN Gateway or ExpressRoute Gateway is NOT orphaned, even if no other subnets have connected resources. The gateway itself is a connected resource, and it is a paid resource that bills hourly. These VNets require a different evaluation: determine whether the gateway is still needed, not whether the VNet is empty.
A VNet qualifies as an orphan candidate when all three conditions are met:
Before deleting an orphaned VNet, you must resolve or acknowledge these dependencies:
Pattern 1: VNets with No Subnets
# Find all VNets with no subnets using Azure Resource Graph
az graph query -q "
Resources
| where type =~ 'microsoft.network/virtualnetworks'
| where array_length(properties.subnets) == 0
| project name, resourceGroup, subscriptionId, location,
addressSpace=properties.addressSpace.addressPrefixes
" -o table
# List peerings on an orphaned VNet before deletion
az network vnet peering list \
--resource-group <rg-name> \
--vnet-name <vnet-name> \
--query "[].{Name:name, RemoteVNet:remoteVirtualNetwork.id, State:peeringState}" \
-o table
# Remove peerings from both sides
az network vnet peering delete \
--resource-group <rg-name> \
--vnet-name <vnet-name> \
--name <peering-name>
# Remove the corresponding peering on the remote VNet
az network vnet peering delete \
--resource-group <remote-rg-name> \
--vnet-name <remote-vnet-name> \
--name <remote-peering-name>
# Delete the orphaned VNet
az network vnet delete \
--resource-group <rg-name> \
--name <vnet-name>Pattern 2: VNets with Subnets but No Connected Resources
# Find VNets where all subnets have no connected resources
az graph query -q "
Resources
| where type =~ 'microsoft.network/virtualnetworks'
| where array_length(properties.subnets) > 0
| mv-expand subnet = properties.subnets
| extend ipConfigCount = array_length(subnet.properties.ipConfigurations),
delegationCount = array_length(subnet.properties.delegations),
serviceEndpointCount = array_length(subnet.properties.serviceEndpoints)
| summarize totalIpConfigs = sum(ipConfigCount),
totalDelegations = sum(delegationCount),
subnetCount = count()
by name, resourceGroup, subscriptionId, location
| where totalIpConfigs == 0 and totalDelegations == 0
| project name, resourceGroup, subscriptionId, location, subnetCount
" -o table
# Check for Private DNS zone links to this VNet
az network private-dns link vnet list \
--resource-group <dns-zone-rg> \
--zone-name <zone-name> \
--query "[?virtualNetwork.id contains '<vnet-name>'].{LinkName:name, VNet:virtualNetwork.id}" \
-o table
# Check for active gateways in the GatewaySubnet
az network vnet-gateway list \
--resource-group <rg-name> \
--query "[?contains(ipConfigurations[0].subnet.id, '<vnet-name>')].{Name:name, SKU:sku.name}" \
-o table
# If gateway exists and is no longer needed, delete it first
az network vnet-gateway delete \
--resource-group <rg-name> \
--name <gateway-name>
# Remove all peerings (both sides)
az network vnet peering list \
--resource-group <rg-name> \
--vnet-name <vnet-name> \
-o tsv --query "[].name" | while read peering; do
az network vnet peering delete \
--resource-group <rg-name> \
--vnet-name <vnet-name> \
--name "$peering"
done
# Delete the orphaned VNet (subnets are deleted with it)
az network vnet delete \
--resource-group <rg-name> \
--name <vnet-name>Decision Tree:
Orphaned VNet Decision Tree
=============================
[Start] Does the VNet have any subnets?
|
+-- NO --> Any peerings?
| |
| +-- YES --> Remove peerings (both sides) --> DELETE VNet
| +-- NO --> DELETE VNet
|
+-- YES --> Do any subnets have connected NICs, gateways, or delegations?
|
+-- YES --> Is the connected resource still active/needed?
| |
| +-- YES --> RETAIN (not orphaned)
| +-- NO --> Remove the inactive resource first --> Re-evaluate
|
+-- NO --> Any VNet peerings?
|
+-- YES --> Is the peering still serving traffic?
| |
| +-- YES --> RECONFIGURE (keep VNet, re-evaluate peering need)
| +-- NO --> Remove peerings (both sides) --> Continue
|
+-- NO --> Any VPN/ExpressRoute Gateway in GatewaySubnet?
|
+-- YES --> Is the gateway still needed?
| |
| +-- YES --> RETAIN (gateway is a paid resource)
| +-- NO --> Delete gateway --> DELETE VNet
|
+-- NO --> DELETE VNetFailure mode warning: Deleting a VNet that has an active peering to a hub network will leave the hub side in a "Disconnected" peering state. Azure does NOT automatically clean up the remote peering. Always delete both sides of every peering connection before deleting the VNet, or the stale peering will generate monitoring alerts and confuse network operations.
| Component | Azure Charge | Notes |
|---|---|---|
| VNet (core resource) | Free | No charge for the VNet itself; cost is operational (IP space, governance) |
| Subnets | Free | No charge; subnets are logical divisions within the VNet |
| VNet Peering (same region) | $0.01/GB ingress + $0.01/GB egress | Charged per GB of data transferred across the peering |
| VNet Peering (cross-region / global) | $0.035-$0.075/GB depending on regions | Higher rate for cross-region; stale peerings to empty VNets incur no data cost but add route table entries |
| VPN Gateway (Basic SKU) | ~$140/month | Billed hourly whether or not traffic flows; anchored to a GatewaySubnet in the VNet |
| VPN Gateway (VpnGw1) | ~$260/month | Common production SKU; significant cost if the VNet carries no traffic |
| ExpressRoute Gateway (Standard) | ~$365/month | Billed hourly; often deployed in hub VNets that may outlive their spoke workloads |
| Azure Firewall | ~$912/month (Standard) | Deployed into AzureFirewallSubnet; continues to bill even with zero traffic |
| Private DNS Zone Link | Free | No charge; auto-cleaned when VNet is deleted |
| NSG / Route Table | Free | No charge; detached (not deleted) when VNet is deleted |
Key insight: While the VNet itself is free, the paid resources anchored to it (VPN Gateways, ExpressRoute Gateways, Azure Firewall) continue to bill at full rate regardless of whether the VNet carries any traffic. Identifying orphaned VNets is often the first step to discovering these idle paid resources.
No. Deleting a VNet is a free operation. The VNet itself has no associated charge. However, if the VNet contains paid resources (VPN Gateway, ExpressRoute Gateway, Azure Firewall), those resources must be deleted first, and their deletion stops the associated hourly billing. The act of deletion does not incur any charge.
No. VNet deletion is permanent. There is no soft-delete or recovery mechanism for Azure Virtual Networks. Once deleted, you must recreate the VNet, subnets, peerings, DNS links, and all other configurations from scratch. This is why the structured workflow recommends tagging, notifying, and waiting before deleting. Always document the VNet's configuration (address space, subnets, peerings, DNS links) before deletion so it can be recreated if needed.
When you delete a VNet, Azure removes the local side of any peering connections. The remote VNet's peering enters a "Disconnected" state. This disconnected peering will remain on the remote VNet indefinitely until someone manually removes it. It generates monitoring alerts, clutters the remote VNet's peering list, and prevents a new peering with the same name from being created. Always delete both sides of peering connections before deleting the VNet.
VNets designated for disaster recovery should be tagged as DR resources and excluded from orphan cleanup workflows. Even if a DR VNet has no active resources today, it may be critical during a failover event. The recommended approach is to tag these VNets with a purpose:dr or retain:true tag and configure your cleanup automation to skip tagged resources. Review DR VNets quarterly to ensure the DR plan is still valid and the VNet configuration still matches the recovery requirements.
Azure automatically removes Private DNS zone virtual network links when the linked VNet is deleted. This is safe behavior -- the DNS zone itself is not affected, and other VNets linked to the same zone continue to resolve normally. However, if the deleted VNet was the only VNet linked to a DNS zone with auto-registration enabled, no new DNS records will be auto-registered until another VNet is linked. Verify DNS resolution requirements before deleting.
The VNet itself is free, but orphaned VNets frequently anchor paid resources that continue to bill. The most common hidden costs are: VPN Gateways (~$140-$260+/month), ExpressRoute Gateways (~$365+/month), and Azure Firewall (~$912+/month). These resources bill hourly regardless of traffic. An orphaned VNet with a forgotten VPN Gateway costs over $1,680/year. Beyond direct costs, the operational overhead of managing stale network inventories, investigating false positives in monitoring, and maintaining IaC configurations for non-existent workloads is a real but harder-to-quantify cost.
Yes. The default limit is 1,000 VNets per subscription per region, which can be increased via a support request. While most organizations never hit this limit, MSPs managing many small tenants or organizations using subscription-per-workload patterns can approach it. Orphaned VNets unnecessarily consume this quota. More practically, the limit on VNet peerings per VNet is 500, and each orphaned VNet with a peering consumes one of those slots on the remote VNet.
MSPs should incorporate VNet cleanup into their quarterly tenant hygiene sweeps. Use Azure Lighthouse to run Resource Graph queries across all managed subscriptions from a single pane. Tag orphaned VNets with a cleanup-candidate tag and the discovery date. Notify the customer's technical contact with a list of orphaned VNets and their address space. Wait 14-30 days for acknowledgement before deletion. Document every deletion in the customer's change log. Spotto automates this cross-tenant detection and notification workflow.
Azure Policy can enforce tagging requirements on VNet creation (e.g., requiring an owner and project tag), which makes it easier to identify the responsible team during cleanup. However, Azure Policy cannot currently detect and auto-delete orphaned VNets. You need a scheduled automation (Azure Automation runbook, Logic App, or third-party tool like Spotto) to periodically scan for VNets matching the orphan criteria and trigger the cleanup workflow.
You need Microsoft.Network/virtualNetworks/delete permission, which is included in the Network Contributor, Contributor, and Owner built-in roles. If the VNet has peerings, you also need Microsoft.Network/virtualNetworks/virtualNetworkPeerings/delete on both the local and remote VNets. For VPN Gateway deletion, you need Microsoft.Network/virtualNetworkGateways/delete. In MSP scenarios using Azure Lighthouse, ensure the delegated role assignment includes these permissions.
Yes, immediately. Once a VNet is deleted, its CIDR block is released and can be assigned to a new VNet in the same or different subscription. There is no cooldown period. This is one of the primary benefits of cleaning up orphaned VNets -- reclaiming fragmented IP address space. If your organization uses an IP Address Management (IPAM) tool, update it to reflect the released range.
Spotto continuously queries Azure Resource Graph across all connected subscriptions to identify VNets matching the orphan criteria: no connected NICs, no active gateways, and no delegated services. It cross-references peering connections, DNS zone links, and gateway deployments to build a complete dependency map for each VNet. Orphaned VNets are flagged with their full dependency context, estimated IP address space recovery, and any anchored paid resources -- giving your team everything needed to make a confident delete-or-retain decision without manual investigation.
Environment: MSP managing 35 tenants across Azure Lighthouse. Quarterly network hygiene review as part of managed services SLA.
Discovery: Resource Graph query across all 35 tenants identified 12 orphaned VNets: 8 with no subnets, 4 with subnets but zero connected resources. Combined address space held by orphaned VNets: 3 x /16 blocks and 9 x /24 blocks.
Dependencies found: 3 VNets had stale peering connections to hub networks (remote side showing "Disconnected"). 1 VNet had 2 Private DNS zone links. No gateways or paid resources were attached.
Actions taken: Tagged all 12 VNets as cleanup-candidate. Notified 8 customer contacts (some tenants had multiple orphans). Waited 14-day grace period. Received acknowledgement from all 8 customers. Deleted stale peerings (both sides) on 3 VNets. Deleted all 12 VNets. Updated IPAM records to release address blocks.
Outcome: Recovered 3 x /16 and 9 x /24 address blocks. Eliminated 3 stale peering connections from hub networks. Reduced network inventory noise across 35 tenants. Total time: 4 hours across the MSP team.
Environment: Enterprise with a hub-spoke network topology. Recently completed migration from on-premises to Azure, consolidating workloads from 6 spoke VNets down to 3.
Discovery: 3 spoke VNets were empty after migration -- all VMs moved to the consolidated spoke VNets. Each orphaned spoke VNet still had: 1 peering to the hub VNet, 3-4 Private DNS zone links, and 2-3 subnets with NSG associations but no connected NICs.
Dependencies found: Hub VNet had 6 peering connections (3 active, 3 to orphaned spokes). The 3 stale peerings were adding route entries to the hub's effective route table. UDR on the hub's firewall subnet included routes pointing at the orphaned spoke address ranges.
Actions taken: Removed stale routes from the hub UDR. Deleted peerings on both sides (3 peerings x 2 sides = 6 peering deletions). Deleted the 3 orphaned spoke VNets. Cleaned up 4 orphaned NSGs that were detached after VNet deletion. Updated hub-spoke Bicep templates to remove references to decommissioned spokes.
Outcome: Hub VNet's effective route table reduced by ~40%. Network topology diagram simplified from 7 VNets to 4. IaC templates now match production state. No cost savings on VNet itself (free), but eliminated confusion during incident response when engineers had to determine which spokes carried active traffic.
Environment: New customer onboarding for an MSP. Customer has 4 subscriptions, previously self-managed.
Discovery: Initial network audit found 2 orphaned VNets in a dev/test subscription. One VNet had no subnets. The second VNet had 3 subnets with no connected NICs, but the GatewaySubnet contained a VPN Gateway (VpnGw1 SKU).
Dependencies found: The VPN Gateway had been deployed 18 months earlier for a proof-of-concept site-to-site connection that was never completed. No VPN connections were configured. The gateway was billing at ~$140/month (VpnGw1 in the dev/test subscription where Basic was not available).
Actions taken: Confirmed with the customer that the VPN Gateway was no longer needed. Deleted the VPN Gateway (stopped $140/month billing). Deleted both orphaned VNets after removing one stale peering.
Outcome: Immediate cost savings of $1,680/year from the forgotten VPN Gateway. This finding during onboarding demonstrated the MSP's value and justified the ongoing managed services engagement. The orphaned VNet was the breadcrumb that led to the paid resource -- a common pattern Spotto surfaces automatically.
Environment: SaaS company with a subscription-per-environment deployment model. Each feature branch gets its own resource group with a VNet, AKS cluster, and supporting resources. Resources are torn down after testing, but VNets are sometimes left behind.
Discovery: Over 6 months, 40+ orphaned VNets accumulated across the dev/test subscription. Each VNet had a /22 address space (1,024 addresses). Combined, the orphaned VNets consumed over 40,000 IP addresses from the dev/test range, which was a /16 (65,536 total). Engineers were requesting additional address space because the dev/test /16 appeared nearly exhausted.
Dependencies found: No peerings, no gateways, no DNS links. All 40+ VNets were Pattern 1 (no subnets) or Pattern 2 (subnets with no connected resources). The AKS clusters, databases, and other resources had been deleted but the VNets remained.
Actions taken: Bulk-deleted all 40+ orphaned VNets using a scripted cleanup. Updated the CI/CD pipeline teardown stage to include VNet deletion as part of the environment cleanup. Added an Azure Policy to require a ttl tag on all VNets in the dev/test subscription.
Outcome: Recovered over 40,000 IP addresses (~62% of the dev/test range). Eliminated the need for additional address space allocation. CI/CD pipeline now includes VNet cleanup, preventing future accumulation. No direct cost savings (VNets are free), but prevented a costly IP re-addressing project.
purpose:dr, retain:true)cleanup-candidate and the discovery date. Exclude VNets tagged with DR or retention markers.cleanup-candidate tag and document the retention reason.VNet deletion is permanent -- there is no undo. The rollback strategy is based on recreation:
The following PowerShell script can be deployed as an Azure Automation runbook on a weekly schedule. It discovers orphaned VNets, tags them, and optionally deletes them after the grace period has elapsed.
# Azure Automation Runbook: Orphaned VNet Cleanup
# Schedule: Weekly, during maintenance window
# Requires: Az.Accounts, Az.Network, Az.ResourceGraph modules
param(
[string[]]$SubscriptionIds, # Target subscriptions (empty = all accessible)
[int]$GracePeriodDays = 14, # Days between tagging and deletion
[string]$CleanupTag = "cleanup-candidate",
[string]$RetainTag = "retain",
[string]$DRTag = "purpose",
[string]$DRTagValue = "dr",
[bool]$DryRun = $true,
[bool]$AutoDeleteAfterGrace = $false # Set to $true for fully automated cleanup
)
Connect-AzAccount -Identity
# If no subscriptions specified, get all accessible
if (-not $SubscriptionIds) {
$SubscriptionIds = (Get-AzSubscription).Id
}
$report = @()
foreach ($subId in $SubscriptionIds) {
Set-AzContext -SubscriptionId $subId | Out-Null
Write-Output "=== Scanning subscription: $subId ==="
# Get all VNets in the subscription
$vnets = Get-AzVirtualNetwork
foreach ($vnet in $vnets) {
# Skip VNets tagged for retention or DR
$tags = $vnet.Tag
if ($tags[$RetainTag] -eq "true" -or $tags[$DRTag] -eq $DRTagValue) {
Write-Output "SKIP (tagged retain/DR): $($vnet.Name) in $($vnet.ResourceGroupName)"
continue
}
$isOrphaned = $false
$reason = ""
# Pattern 1: No subnets
if ($vnet.Subnets.Count -eq 0) {
$isOrphaned = $true
$reason = "No subnets"
}
else {
# Pattern 2: Subnets with no connected resources
$hasConnectedResources = $false
foreach ($subnet in $vnet.Subnets) {
if ($subnet.IpConfigurations.Count -gt 0) {
$hasConnectedResources = $true
break
}
if ($subnet.Delegations.Count -gt 0) {
$hasConnectedResources = $true
break
}
# Check for gateway in GatewaySubnet
if ($subnet.Name -eq "GatewaySubnet") {
$gateways = Get-AzVirtualNetworkGateway -ResourceGroupName $vnet.ResourceGroupName -ErrorAction SilentlyContinue
foreach ($gw in $gateways) {
foreach ($ipConfig in $gw.IpConfigurations) {
if ($ipConfig.Subnet.Id -eq $subnet.Id) {
$hasConnectedResources = $true
break
}
}
}
}
}
if (-not $hasConnectedResources) {
$isOrphaned = $true
$reason = "Subnets with no connected resources ($($vnet.Subnets.Count) empty subnets)"
}
}
if ($isOrphaned) {
$addressSpace = ($vnet.AddressSpace.AddressPrefixes -join ", ")
$peeringCount = $vnet.VirtualNetworkPeerings.Count
# Check if already tagged for cleanup
$taggedDate = $tags[$CleanupTag]
if (-not $taggedDate) {
# First discovery -- tag it
Write-Output "[NEW ORPHAN] $($vnet.Name) | RG: $($vnet.ResourceGroupName) | Reason: $reason | Address: $addressSpace | Peerings: $peeringCount"
if (-not $DryRun) {
$newTags = $tags ?? @{}
$newTags[$CleanupTag] = (Get-Date -Format "yyyy-MM-dd")
Set-AzResource -ResourceId $vnet.Id -Tag $newTags -Force | Out-Null
Write-Output " -> Tagged with $CleanupTag = $(Get-Date -Format 'yyyy-MM-dd')"
} else {
Write-Output " -> [DRY RUN] Would tag with $CleanupTag"
}
$report += [PSCustomObject]@{
Action = "Tagged"
VNetName = $vnet.Name
ResourceGroup = $vnet.ResourceGroupName
Subscription = $subId
Reason = $reason
AddressSpace = $addressSpace
Peerings = $peeringCount
}
}
elseif ($AutoDeleteAfterGrace) {
# Already tagged -- check grace period
$tagDate = [datetime]::Parse($taggedDate)
$age = (Get-Date) - $tagDate
if ($age.TotalDays -ge $GracePeriodDays) {
Write-Output "[GRACE EXPIRED] $($vnet.Name) | Tagged: $taggedDate | Age: $([math]::Round($age.TotalDays)) days"
if (-not $DryRun) {
# Remove peerings first (both sides)
foreach ($peering in $vnet.VirtualNetworkPeerings) {
$remoteVNetId = $peering.RemoteVirtualNetwork.Id
# Delete local peering
Remove-AzVirtualNetworkPeering -VirtualNetworkName $vnet.Name \
-ResourceGroupName $vnet.ResourceGroupName \
-Name $peering.Name -Force
Write-Output " -> Deleted local peering: $($peering.Name)"
# Delete remote peering
$remoteVNetName = ($remoteVNetId -split "/")[-1]
$remoteRG = ($remoteVNetId -split "/")[4]
$remotePeerings = Get-AzVirtualNetworkPeering -VirtualNetworkName $remoteVNetName \
-ResourceGroupName $remoteRG -ErrorAction SilentlyContinue
foreach ($rp in $remotePeerings) {
if ($rp.RemoteVirtualNetwork.Id -eq $vnet.Id) {
Remove-AzVirtualNetworkPeering -VirtualNetworkName $remoteVNetName \
-ResourceGroupName $remoteRG -Name $rp.Name -Force
Write-Output " -> Deleted remote peering: $($rp.Name) on $remoteVNetName"
}
}
}
# Delete the VNet
Remove-AzVirtualNetwork -Name $vnet.Name \
-ResourceGroupName $vnet.ResourceGroupName -Force
Write-Output " -> DELETED VNet: $($vnet.Name)"
} else {
Write-Output " -> [DRY RUN] Would delete VNet and $peeringCount peering(s)"
}
$report += [PSCustomObject]@{
Action = "Deleted"
VNetName = $vnet.Name
ResourceGroup = $vnet.ResourceGroupName
Subscription = $subId
Reason = $reason
AddressSpace = $addressSpace
Peerings = $peeringCount
}
}
else {
Write-Output "[GRACE PENDING] $($vnet.Name) | Tagged: $taggedDate | Days remaining: $([math]::Round($GracePeriodDays - $age.TotalDays))"
$report += [PSCustomObject]@{
Action = "Grace pending"
VNetName = $vnet.Name
ResourceGroup = $vnet.ResourceGroupName
Subscription = $subId
Reason = $reason
AddressSpace = $addressSpace
Peerings = $peeringCount
}
}
}
}
}
}
# Output summary
Write-Output ""
Write-Output "=== SUMMARY ==="
Write-Output "Total orphaned VNets found: $($report.Count)"
Write-Output " Tagged (new): $(($report | Where-Object Action -eq 'Tagged').Count)"
Write-Output " Deleted: $(($report | Where-Object Action -eq 'Deleted').Count)"
Write-Output " Grace pending: $(($report | Where-Object Action -eq 'Grace pending').Count)"
Write-Output ""
$report | Format-Table -AutoSizeFailure mode warning: Always run the runbook with $DryRun = $true first. Review the output to confirm that no VNets tagged for retention, DR, or active use are included in the cleanup list. The $AutoDeleteAfterGrace flag defaults to $false -- set it to $true only after you have validated the discovery and tagging phases across at least two manual cycles.
The challenges outlined in this article — orphaned VNets fragmenting IP address space, stale peering connections, VPN Gateways anchored to empty networks — are the same ones our customers work through across their Azure environments. The difference is they use Spotto to surface these opportunities automatically, rather than running the analysis by hand.
Spotto continuously analyzes your Azure networking footprint to detect orphaned VNets, stale peering connections, and unused gateways — giving you clear, actionable recommendations to keep your network inventory clean across every subscription.
Free TrialDisclaimer: This article is provided for informational purposes only and does not constitute professional advice. Azure pricing, features, and service behaviour may have changed since publication. Always validate recommendations against your specific environment and consult Microsoft's official documentation before making changes to production infrastructure. Spotto is not liable for any actions taken based on the content of this article.