A few days ago I asked on r/devsarg how people find unattached disks and orphaned IPs before they show up on the bill. I manage OCI, Azure and AWS accounts, and every so often a 200 GB disk turns up that was left behind by a VM deleted months earlier. The thread got 48 comments and almost all of them told the same story under a different name: the disk somebody discovers a year later, the ten-year-old resource that predates any tagging policy, the Kubernetes volumes the CSI driver detached and nobody looked at again. On r/googlecloud, the same week, someone had cleaned up their dev VMs and the invoice barely moved: what kept growing month over month was storage, including snapshots of disks that no longer existed.

This post is the long answer to both questions. Which resources are born with the VM and do not die with it on each cloud, one read-only list per provider to see them, and what that list cannot tell you.

Why it happens: the console lists VMs, the bill lists resources

The instances page shows instances. The disk, the public IP and the snapshot are separate resources, each with its own ID and its own line on the invoice. When you delete the VM, each of them follows whatever rule it had configured, and that rule was almost never chosen on purpose by anyone.

  • AWS. Every EBS volume has a DeleteOnTermination attribute. According to the EC2 guide, the root volume created at launch is deleted with the instance; a data volume added at launch from the console is preserved (from the CLI it is deleted), and any volume attached after launch is preserved. Preserved volumes keep being charged. The Elastic IP is an independent resource: since February 2024 AWS charges USD 0.005 per hour for every public IPv4 address, and the rate is the same for an in-use IP and an idle one. Snapshots never depend on the volume: they outlive the volume and the instance.
  • GCP. Every attached disk has autoDelete. For the boot disk created with the instance the default is yes; for an existing disk you attach with --disk the default is no, and the gcloud reference notes that if the disk is detached later the option no longer applies. A reserved static external address sits in RESERVED status when it is not assigned to anything, and the network pricing page says an unassigned reserved static IP is charged at a higher rate than one in use. Snapshots keep the source disk name in sourceDisk, but they are not deleted with it.
  • Azure. The public IP, the NIC and the OS disk are resources separate from the VM. At creation, each one has a deleteOption that can be Delete or Detach; the portal checks "Delete with VM" for the OS disk by default, but the default when you create the VM from the CLI or PowerShell is Detach, and the public IP has its own checkbox. A disk left behind shows diskState as Unattached.
  • OCI. According to the Compute documentation, when you terminate an instance the boot volume is preserved by default; deleting it with the instance is an option you have to pick. Block volumes get detached and stay in lifecycleState AVAILABLE, which is the same state they have while attached, so the state alone is not enough. A reserved public IP that is not assigned to anything has assignedEntityId set to null.

The four lists, read-only

None of these commands deletes anything. All of them come from each CLI's official reference. I keep the fields that help you decide afterwards: size, creation date and tags.

# AWS: volumes with no instance, Elastic IPs with no association, and your own snapshots with their source volume aws ec2 describe-volumes --filters Name=status,Values=available --query "Volumes[].{id:VolumeId,gb:Size,created:CreateTime,tags:Tags}" aws ec2 describe-addresses --query "Addresses[?AssociationId==null].[PublicIp,AllocationId]" aws ec2 describe-snapshots --owner-ids self --query "Snapshots[].[SnapshotId,VolumeId,VolumeSize,StartTime]" # cross-check each snapshot's VolumeId against describe-volumes: if it is missing, the disk is gone # GCP: disks with no users, reserved static addresses not assigned, and snapshots with their source disk gcloud compute disks list --filter="-users:*" --format="table(name,zone.basename(),sizeGb,creationTimestamp)" gcloud compute addresses list --filter="status=RESERVED" gcloud compute snapshots list --format="table(name,sourceDisk.basename(),diskSizeGb,creationTimestamp)" # Azure: Unattached disks, public IPs with no ipConfiguration, and snapshots with their source resource az disk list --query "[?diskState=='Unattached'].{name:name,rg:resourceGroup,gb:diskSizeGb,created:timeCreated}" -o table az network public-ip list --query "[?ipConfiguration==null].{name:name,rg:resourceGroup,ip:ipAddress}" -o table az snapshot list --query "[].{name:name,source:creationData.sourceResourceId,created:timeCreated}" -o table # OCI: AVAILABLE does not mean detached; the cross-check is against the attachments oci bv volume list --compartment-id "$C" --lifecycle-state AVAILABLE --all --query 'data[].{id:id,name:"display-name",gb:"size-in-gbs",created:"time-created"}' oci compute volume-attachment list --compartment-id "$C" --all --query 'data[?"lifecycle-state"==`ATTACHED`]."volume-id"' # boot volumes that survived a terminate: same cross-check, per availability domain oci bv boot-volume list --compartment-id "$C" --availability-domain "$AD" --all oci compute boot-volume-attachment list --compartment-id "$C" --availability-domain "$AD" --all # reserved public IPs not assigned oci network public-ip list --compartment-id "$C" --scope REGION --lifetime RESERVED --all --query 'data[?"assigned-entity-id"==null].{ip:"ip-address",state:"lifecycle-state"}' 

Two notes on the lists. On OCI the volume state does not distinguish attached from detached, which is why there are two commands and a subtraction. And on all four clouds the IPs deserve their own pass, because they are the only one of these resources that costs the same in use as unused; the per-provider detail is in public IPs that keep charging.

What the list does not tell you

The list says which disk is detached. It does not say whether it is garbage. That was the most discussed part of the thread, and I think it is the genuinely hard part:

  • Since when it has been detached. describe-volumes returns CreateTime, not the detach date. No provider records the moment a disk lost its VM; if you want a 14 or 30 day grace period, you keep the clock yourself: a tag with the date you first saw it, or a table that carries the record between runs.
  • Who created it. That comes from the provider's audit log, not from the resource. And the ten-year-old resource that predates the tagging policy has no owner and no cost center: no filter resolves that one, a person who recognises it does.
  • Kubernetes volumes. A PersistentVolume with persistentVolumeReclaimPolicy: Retain survives the deletion of its PVC: the PV goes to Released and the cloud disk keeps existing, detached, with the PV still pointing at it. To the list it is one more unattached disk; to the team that set Retain, it is a backup.
  • The disk with the mdf and ldf files of a SQL Server from 2018 that somebody kept on purpose. One comment in the thread described exactly that, and it is the reason almost nobody runs automatic deletion without a human review in production.

A script lists; a person decides. The script that came out of the thread is on GitHub as cloud-orphans: it lists unattached disks and unassociated IPs across the four clouds, with the age of each one, and deletes nothing. Today the OCI output is real and the rest runs on test data, and it does not yet store the first time it saw each resource, which is exactly what the grace period needs.

Seeing it without running the four lists

Control Cloud reads the account through the provider API with a read-only credential and draws the map of what is there: the VM, its subnet, its disks, its IPs. A disk or an IP left without a VM shows up on the map with nothing attached, so you see it without running the four lists or cross-checking outputs by hand. What it does not do is decide what to delete: that stays yours. The provider guides explain how to connect each AWS, GCP, Azure or OCI account with read permissions, and if you want to try it with yours, request access.