CloudFinOps logo
CloudFinOps
Azure

How to Tell if an Azure VM is Actually Needed vs. Forgotten (The 30-Day Signal Method)

Nishant Jain

Nishant Jain

Co-Founder & CTO · · 11 min read

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 TypeCPUMemoryNetworkDiskActual Purpose
Cache server3%85%2 GB/dayLowServing cached data from RAM
Reverse proxy5%10%50 GB/dayLowForwarding traffic (network-bound)
Log collector2%20%Low500K IOPS/dayWriting logs to disk
Standby/DR0%5%00Waiting for failover
Truly idle0.3%5%<1 MB/day0Nothing. 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 PatternLikely StatusAction
Environment: productionActive, protectedDon't touch without architecture review
Environment: dev or stagingPotentially idleSafe to stop, wait for complaints
Owner: [specific person]Has an ownerAsk the owner before acting
No tags at allLikely forgottenHigh probability zombie
DR: true or Failover: activeDisaster recoveryNever terminate (it exists for emergencies)
Name contains test, demo, tmp, experimentTemporarySafe 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):

SignalScore 0 (Active)Score 0.5 (Uncertain)Score 1 (Idle)
Multi-metricAny metric above thresholdMixed signalsAll metrics below threshold, 30 days
ConnectionsIn LB pool or availability setHas NSG rules allowing inboundNo inbound connections at all
Age/ActivityModified within 14 daysModified 14-60 days agoNo activity for 60+ days
TagsProduction tag or Owner tagHas some tags but no environmentNo tags at all
TopologyDNS, public IP, data disksPrivate IP only but in active subnetIsolated, 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.

Start Free Audit

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:

  1. Azure Advisor handles the basic CPU check (14-day lookback, 5% threshold)
  2. Azure Resource Graph queries can find untagged VMs and VMs not in any availability set
  3. Azure Monitor Alerts can detect VMs with zero network traffic for 7+ consecutive days
  4. 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).

Related articles