The False Positive That Costs You Trust
An engineer gets an alert: "VM demo-oversized-vm has been idle for 14 days. Estimated savings: $140/month. Recommended action: Terminate."
They terminate it.
Two hours later, the oncall Slack channel explodes. That "idle" VM was a hot standby for the payment processing service. It sits at 0% CPU because it only activates during failover. Now the failover path is broken, and the team has to spin it back up during an incident.
This scenario destroys trust in optimization tooling faster than anything else. One false positive, one wrong termination, and the engineering team will never trust another "idle resource" recommendation again.
The solution isn't to ignore idle VMs (they cost real money). The solution is a systematic method that distinguishes truly abandoned resources from resources with a purpose you haven't noticed yet.
The 30-Day Signal Method
Instead of checking one metric at one point in time, gather 5 independent signals over a 30-day window. A resource is safe to terminate only when ALL signals agree.
Signal 1: Multi-Metric Utilization (CPU + Memory + Network + Disk)
A VM can be "idle" on CPU but very much in use on other dimensions:
| VM Type | CPU | Memory | Network | Disk | Actual Purpose |
|---|---|---|---|---|---|
| Cache server | 3% | 85% | 2 GB/day | Low | Serving cached data from RAM |
| Reverse proxy | 5% | 10% | 50 GB/day | Low | Forwarding traffic (network-bound) |
| Log collector | 2% | 20% | Low | 500K IOPS/day | Writing logs to disk |
| Standby/DR | 0% | 5% | 0 | 0 | Waiting for failover |
| Truly idle | 0.3% | 5% | <1 MB/day | 0 | Nothing. Forgotten. |
Only the last row is a genuine zombie. The first four are doing their job. CPU alone cannot distinguish them.
Azure CLI to check all 4 metrics:
VM_RESOURCE_ID="/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{name}"
# CPU (30-day average)
az monitor metrics list --resource $VM_RESOURCE_ID \
--metric "Percentage CPU" --interval PT1H \
--start-time $(date -u -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ) \
--aggregation Average --output table
# Network (30-day total inbound)
az monitor metrics list --resource $VM_RESOURCE_ID \
--metric "Network In Total" --interval P1D \
--start-time $(date -u -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ) \
--aggregation Total --output table
# Disk read operations
az monitor metrics list --resource $VM_RESOURCE_ID \
--metric "Disk Read Operations/Sec" --interval P1D \
--start-time $(date -u -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ) \
--aggregation Average --output table
Idle threshold (ALL must be true simultaneously):
- CPU average < 5% for 30 days
- Network In < 1 MB/day for 30 days
- Network Out < 1 MB/day for 30 days
- Disk Operations < 100/day for 30 days
Signal 2: Connection State (Who Talks to This VM?)
Even with zero CPU, a VM might be receiving health checks or SSH probes that indicate something depends on it.
Check Network Security Group (NSG) flow logs or VM connection state:
# Check if any load balancer has this VM in its backend pool
az network lb list --query "[].backendAddressPools[].backendAddresses[?virtualNetwork!=null].virtualNetwork" -o table
# Check if VM is in an availability set (potential failover peer)
az vm show --resource-group {rg} --name {vm-name} \
--query "availabilitySet.id" -o tsv
If the VM is in a load balancer backend pool or availability set, it has a structural purpose regardless of current utilization. Do not terminate without understanding the architecture.
Signal 3: Age and Modification History
Recently created VMs might be in setup/deployment and haven't started receiving traffic yet. Old VMs that haven't been modified are more likely forgotten.
# When was this VM last modified?
az vm show --resource-group {rg} --name {vm-name} \
--query "{Created:timeCreated, LastModified:properties.timeCreated}" -o table
# Check activity log for any operations on this VM in the last 30 days
az monitor activity-log list \
--resource-id $VM_RESOURCE_ID \
--start-time $(date -u -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ) \
--query "[].{Operation:operationName.localizedValue, Time:eventTimestamp, Caller:caller}" \
--output table
Zombie signal: No activity log entries (no SSH, no restart, no config change, no deployment) for 30+ days.
Not-zombie signal: Recent SSH sessions, deployment operations, or configuration changes within 14 days.
Signal 4: Tags and Naming Convention
Tags are the cheapest signal to check and often the most reliable:
| Tag Pattern | Likely Status | Action |
|---|---|---|
Environment: production | Active, protected | Don't touch without architecture review |
Environment: dev or staging | Potentially idle | Safe to stop, wait for complaints |
Owner: [specific person] | Has an owner | Ask the owner before acting |
| No tags at all | Likely forgotten | High probability zombie |
DR: true or Failover: active | Disaster recovery | Never terminate (it exists for emergencies) |
Name contains test, demo, tmp, experiment | Temporary | Safe to stop after 14 days idle |
# Get tags for a VM
az vm show --resource-group {rg} --name {vm-name} --query "tags" -o json
The no-tags rule: A VM with zero tags that's been running for 60+ days is almost certainly forgotten. Nobody who actively manages a resource leaves it untagged for two months.
Signal 5: Network Topology (What's Upstream and Downstream?)
The most sophisticated signal: does this VM have dependencies or dependents?
# Check NIC configuration (subnet, public IP, NSG)
az vm nic list --resource-group {rg} --vm-name {vm-name} -o table
# Check if VM has any attached data disks (storage dependency)
az vm show --resource-group {rg} --name {vm-name} \
--query "storageProfile.dataDisks[].{Name:name, Size:diskSizeGb}" -o table
# Check DNS records pointing to this VM's IP
az network public-ip show --resource-group {rg} --name {ip-name} \
--query "{IP:ipAddress, FQDN:dnsSettings.fqdn}" -o table
Zombie signal: No public IP, no DNS record, no data disks, subnet only contains this single VM.
Not-zombie signal: DNS record pointing to it, data disks attached (might be storing something critical), multiple NICs (likely a network appliance), or same subnet as known production resources.
The Scoring System
Score each signal from 0 (definitely active) to 1 (definitely idle):
| Signal | Score 0 (Active) | Score 0.5 (Uncertain) | Score 1 (Idle) |
|---|---|---|---|
| Multi-metric | Any metric above threshold | Mixed signals | All metrics below threshold, 30 days |
| Connections | In LB pool or availability set | Has NSG rules allowing inbound | No inbound connections at all |
| Age/Activity | Modified within 14 days | Modified 14-60 days ago | No activity for 60+ days |
| Tags | Production tag or Owner tag | Has some tags but no environment | No tags at all |
| Topology | DNS, public IP, data disks | Private IP only but in active subnet | Isolated, no dependencies |
Total score (sum of all 5):
- 0 to 1.5: Active resource. Do not touch.
- 1.5 to 3.0: Uncertain. Investigate further or ask the team.
- 3.0 to 4.0: Likely idle. Safe to stop (not terminate). Wait 7 days for complaints.
- 4.0 to 5.0: Almost certainly zombie. Terminate after 7-day stopped verification.
See how much you're wasting
Get a free 7-day cloud audit. No credit card, no agents, read-only access.
The Safe Termination Process
Even when all signals say "zombie," follow this process:
Day 0: Stop (don't terminate)
az vm deallocate --resource-group {rg} --name {vm-name}
Deallocating stops compute charges but keeps the VM definition and disks. If someone needs it, they can restart in 2 minutes.
Day 0-7: Monitor for complaints
Post in the engineering Slack channel: "Stopped VM {name} in {resource-group} due to 30+ days idle across all metrics. If you need this, reply here. Otherwise it will be deleted on {date}."
Day 7: Snapshot and terminate
If nobody responded:
# Snapshot the OS disk (insurance)
az snapshot create --resource-group {rg} \
--name {vm-name}-final-snapshot \
--source /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/disks/{disk-name}
# Terminate the VM
az vm delete --resource-group {rg} --name {vm-name} --yes
# Delete orphaned NIC and public IP
az network nic delete --resource-group {rg} --name {vm-name}-nic
az network public-ip delete --resource-group {rg} --name {vm-name}-ip
Day 30: Delete the snapshot
If nobody asked to restore from the snapshot after 30 days, delete it to avoid snapshot storage charges.
Automating This at Scale
For teams with 50+ VMs, manual signal checking doesn't scale. Here's how to automate:
- Azure Advisor handles the basic CPU check (14-day lookback, 5% threshold)
- Azure Resource Graph queries can find untagged VMs and VMs not in any availability set
- Azure Monitor Alerts can detect VMs with zero network traffic for 7+ consecutive days
- Tools like CloudFinOps automate all 5 signals simultaneously, apply the scoring system, and generate recommendations with evidence chains so you can see exactly WHY a VM was classified as idle
The key difference between manual optimization and automated optimization is confidence. Manual checks always leave doubt: "what if I missed something?" Automated systems that check 5 signals with 30 days of data and show you the evidence let you act with confidence.
FAQ
What about VMs that are idle 95% of the time but critical during the 5%?
This is the autoscaling use case. If a VM has clear peak periods (even brief ones), it shouldn't be terminated or rightsized. It should be replaced with an auto-scaling group that scales from 0 during idle periods and spins up for the brief active window. Azure Virtual Machine Scale Sets support scale-to-zero for workloads that genuinely idle most of the time.
How do I handle VMs that belong to someone who left the company?
This is the "no Owner tag" problem. VMs without an owner tag where the only person who accessed them has left the organization are almost certainly zombies. Follow the safe termination process: stop, announce in Slack, wait 7 days, snapshot, delete. The 7-day window catches cases where another team member depended on it without being the direct owner.
Should I use Azure Advisor recommendations or build my own detection?
Start with Azure Advisor. It's free and catches the obvious cases. But Advisor has limitations: it only checks 14 days, it only checks CPU, and it doesn't consider topology or tags. For teams with 20+ VMs, supplement Advisor with multi-signal analysis (what this article describes) to catch the cases Advisor misses and avoid the false positives Advisor generates.
What's the cost of being wrong (terminating something needed)?
If you followed the process (stop first, wait 7 days, snapshot before delete), the cost of being wrong is: 2 minutes of downtime while someone restarts the VM, or 5 minutes to restore from snapshot if the VM was deleted. This is why we never skip the stop-and-wait phase. The snapshot is your insurance policy against permanent data loss.
How often should I audit for idle resources?
Monthly is the right cadence for manual audits. Weekly for automated scanning. The key metric to track: "percentage of VMs classified as zombie vs. total VMs." A healthy infrastructure runs at 0-5% zombie rate. Above 10% indicates a systemic governance problem (no tagging policy, no cleanup automation).

