Idle AWS Resources: Find and Delete Them Safely

Find and clean up idle AWS resources: unattached EBS volumes, orphaned snapshots, idle Elastic IPs, unused load balancers and NAT gateways, with safe CLI.

Every AWS account we’ve ever looked at has a basement. It’s where the volume from last year’s proof of concept lives, next to a dozen snapshots of a database nobody runs anymore and an Elastic IP that used to point at a bastion host. None of it shows up on a dashboard as broken. It just bills, quietly, every hour.

Each line is small on its own, and that’s how it survives. A sandbox account holding thirty detached 50 GB gp3 volumes pays $120 a month for them. Add the snapshots someone took of those volumes “just in case”, a few stray public addresses and a load balancer with no targets, and you’re paying for a small production workload that doesn’t exist.

This post walks through the usual suspects, the read-only CLI commands we use to find each one, and a cleanup routine that won’t get you paged at 2 a.m. Prices are on-demand list prices in us-east-1 at the time of writing (October 2026), and monthly figures assume 730 hours.

Unattached EBS volumes

An EBS volume in the available state isn’t attached to anything, but it’s billed for every provisioned GB exactly as if it were. They pile up because terminating an instance only deletes volumes marked DeleteOnTermination, and extra data volumes usually aren’t.

Volume type us-east-1 list price
gp3 $0.08 per GB-month (3,000 IOPS and 125 MiB/s included)
gp2 $0.10 per GB-month
Magnetic (standard) $0.05 per GB-month

To list them in one region:

# Read-only: unattached volumes with size, type and creation date
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[].{ID:VolumeId,GB:Size,Type:VolumeType,Created:CreateTime,AZ:AvailabilityZone}' \
  --output table

Waste rarely sits in the region you work in, so loop over all of them:

for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  echo "== $r"
  aws ec2 describe-volumes --region "$r" --filters Name=status,Values=available \
    --query 'Volumes[].[VolumeId,Size,VolumeType,CreateTime]' --output text
done

A volume that’s been detached for a week might belong to someone mid-migration. One that’s been detached since 2023 almost certainly doesn’t. Check tags and CloudTrail for who created it before you decide.

While you’re in there: gp2 to gp3

Not idle, but it lives next door. gp3 is 20% cheaper per GB than gp2 and the change is an online modification, no detach or reboot. The catch is performance. A gp2 volume’s baseline IOPS scales with its size, so a large gp2 volume can have more IOPS or throughput than gp3’s included baseline, and you’d pay for extra IOPS ($0.005 per provisioned IOPS-month) or throughput ($0.04 per MiB/s-month) to match it. Look at the volume’s CloudWatch VolumeReadOps and VolumeWriteOps before converting anything big or busy. For typical boot and app volumes under about 170 GB, it’s free money. Above that, check VolumeReadBytes and VolumeWriteBytes as well: gp2 can burst to 250 MiB/s from 170 GiB and holds 250 MiB/s from 334 GiB, while gp3 includes 125 MiB/s.

# Read-only: every gp2 volume still around
aws ec2 describe-volumes --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[].[VolumeId,Size,State]' --output text

# CHANGES THE VOLUME (online, but not instantly reversible: AWS limits how often you can modify a volume)
aws ec2 modify-volume --volume-id vol-0123456789abcdef0 --volume-type gp3

Old and orphaned EBS snapshots

Snapshots cost $0.05 per GB-month in the standard tier, and they’re incremental. That second fact matters in both directions. A long chain of daily snapshots can be cheaper than you fear, and deleting an old snapshot can save less than its size suggests, because any blocks still referenced by a newer snapshot are kept. The Cost Explorer line is the truth; the VolumeSize field is a ceiling.

Three kinds deserve a look:

  1. Snapshots whose source volume no longer exists. Sometimes deliberate (a final backup), often not.
  2. Snapshots behind AMIs nobody launches. Deregistering an AMI leaves its snapshots in place by default, so old golden images keep billing long after they’ve been forgotten.
  3. Snapshots far older than any retention policy you’d defend in a meeting.
# Read-only: your snapshots more than a year old (GNU date first, macOS date as the fallback)
CUTOFF=$(date -u -d '1 year ago' +%Y-%m-%d 2>/dev/null || date -u -v-1y +%Y-%m-%d)
aws ec2 describe-snapshots --owner-ids self \
  --query "Snapshots[?StartTime<='$CUTOFF'].[SnapshotId,VolumeId,VolumeSize,StartTime,Description]" \
  --output text

# Read-only: snapshot IDs used by your AMIs (don't delete these directly)
aws ec2 describe-images --owners self \
  --query 'Images[].BlockDeviceMappings[].Ebs.SnapshotId' --output text | tr '\t' '\n' | sort -u > ami-snaps.txt

# Read-only: snapshots whose source volume is gone, minus the ones your AMIs still use
aws ec2 describe-volumes --query 'Volumes[].VolumeId' --output text | tr '\t' '\n' | sort -u > vols.txt
aws ec2 describe-snapshots --owner-ids self --query 'Snapshots[].[VolumeId,SnapshotId]' --output text \
  | sort | join -v1 - vols.txt | grep -vFf ami-snaps.txt

When you retire an AMI, aws ec2 deregister-image --image-id ami-... --delete-associated-snapshots removes its snapshots in the same call (destructive, obviously). A snapshot shared with another AMI is left alone.

Archive or Recycle Bin instead of delete

Some snapshots you have to keep: end-of-project images, compliance copies, the yearly backup legal asked for. For those, the EBS Snapshots Archive tier costs $0.0125 per GB-month, a quarter of the standard rate. It has a 90-day minimum, restores cost $0.03 per GB, and an archived snapshot is stored as a full copy, not an increment. Archiving one member of a daily chain can therefore cost more than leaving it. It suits long-lived, rarely restored, standalone snapshots and nothing else.

Recycle Bin is the other tool. A retention rule catches deleted snapshots and EBS-backed AMIs and keeps them recoverable for 1 to 365 days (volumes can be kept for up to 7). Snapshots in the bin bill at the normal snapshot rate, so a long retention period quietly undoes your cleanup. We use about a week.

Public IPv4 addresses, including idle Elastic IPs

Since 1 February 2024, AWS charges $0.005 per hour for every public IPv4 address, whether it’s in use or not. That’s $3.65 a month, or about $44 a year, per address. Elastic IPs, auto-assigned public IPs, the addresses behind internet-facing load balancers and NAT gateways all count. Addresses you bring through BYOIP don’t.

An unassociated Elastic IP is the purest waste here: it does nothing and it costs the same as one serving traffic.

# Read-only: Elastic IPs not associated with anything
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId,Tags[?Key==`Name`]|[0].Value]' \
  --output table

# Read-only: every public IPv4 on a network interface, and what owns it
aws ec2 describe-network-interfaces \
  --query 'NetworkInterfaces[?Association.PublicIp!=null].[Association.PublicIp,InterfaceType,Description]' \
  --output table

In Cost Explorer, filter on usage types containing PublicIPv4:IdleAddress and PublicIPv4:InUseAddress. VPC IP Address Manager also has a Public IP Insights view that breaks the addresses down by type at no cost. Before releasing an EIP, check DNS records and partner allow-lists. Once released, you rarely get the same address back.

Load balancers with nothing behind them

An Application or Network Load Balancer costs $0.0225 per hour, about $16.43 a month, before any capacity units. An internet-facing one also carries at least one public IPv4 address per enabled Availability Zone. So a forgotten ALB across two zones runs roughly $24 a month to serve nobody.

The signs: no registered targets, all targets unhealthy for weeks, or near-zero traffic.

# Read-only: target health for each target group of a load balancer
aws elbv2 describe-target-groups --load-balancer-arn <alb-arn> \
  --query 'TargetGroups[].TargetGroupArn' --output text \
  | xargs -n1 aws elbv2 describe-target-health --query 'TargetHealthDescriptions[].TargetHealth.State' --target-group-arn

# Read-only: daily request count for the last two weeks (ALB; GNU date, then the macOS form)
START=$(date -u -d '14 days ago' +%Y-%m-%dT00:00:00Z 2>/dev/null || date -u -v-14d +%Y-%m-%dT00:00:00Z)
END=$(date -u +%Y-%m-%dT00:00:00Z)
aws cloudwatch get-metric-statistics --namespace AWS/ApplicationELB --metric-name RequestCount \
  --dimensions Name=LoadBalancer,Value=app/my-alb/50dc6c495c0c9188 \
  --start-time "$START" --end-time "$END" \
  --period 86400 --statistics Sum

For NLBs, use AWS/NetworkELB and NewFlowCount. Be careful with failover and DR load balancers. Zero traffic is their normal state, and that’s the point of them.

Stopped instances still paying for storage

Stopping an instance ends the compute charge. It doesn’t stop EBS billing, and an Elastic IP attached to a stopped instance keeps costing $0.005 an hour. An instance stopped “for a week” in 2024 is a set of volumes and an address you’ve been renting ever since.

# Read-only: stopped instances and when they were stopped
aws ec2 describe-instances --filters Name=instance-state-name,Values=stopped \
  --query 'Reservations[].Instances[].[InstanceId,InstanceType,StateTransitionReason,Tags[?Key==`Name`]|[0].Value]' \
  --output text

StateTransitionReason usually carries the stop timestamp. Anything stopped for more than a couple of months is a candidate: snapshot it, create an AMI if you might want it back, and terminate.

NAT gateways nobody routes through

A NAT gateway costs $0.045 per hour (about $32.85 a month) plus $0.045 per GB processed, and it holds a public IPv4 address too. Teams often create one per AZ per VPC from a template, then delete the workloads and keep the VPC. If BytesOutToDestination is flat at zero, it’s a candidate.

# Read-only: NAT gateways that are up
aws ec2 describe-nat-gateways --filter Name=state,Values=available \
  --query 'NatGateways[].[NatGatewayId,VpcId,SubnetId,CreateTime]' --output table

If the gateway is busy rather than idle, the fix is different. We cover that in NAT gateway and data transfer costs.

Old RDS manual snapshots

Automated RDS backups disappear with the instance. Manual snapshots don’t, by design. RDS gives you free backup storage up to your total provisioned database storage in the region, but once the instance is gone, its snapshots tend to land as billable storage at $0.095 per GB-month for RDS engines (Aurora is $0.021). A 500 GB final snapshot of a decommissioned database is about $47 a month for as long as nobody asks.

# Read-only: manual RDS snapshots and the instance they came from
aws rds describe-db-snapshots --snapshot-type manual \
  --query 'DBSnapshots[].[DBSnapshotIdentifier,DBInstanceIdentifier,AllocatedStorage,SnapshotCreateTime]' \
  --output table

# Read-only: instances that still exist, to compare against
aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier' --output text

For Aurora, use describe-db-cluster-snapshots --snapshot-type manual. If you genuinely need the data for years, exporting the snapshot to S3 and lifecycling it into a colder class is often cheaper than keeping the snapshot. Our S3 storage classes guide covers how.

A cleanup routine that won’t hurt anyone

Deleting things is easy. Deleting the right things without an incident takes a little process. Ours looks like this:

  1. Inventory everywhere. Every region, every account. The commands above, run through an org-wide role.
  2. Tag, don’t delete. Mark candidates with the date you flagged them, as cleanup-candidate=YYYY-MM-DD, plus a reason such as cleanup-reason=unattached-90d. Tags are cheap and reversible.
  3. Tell people. Post the list where owners will see it, with the date it goes. If nobody claims an item within two weeks, it goes.
  4. Keep a way back. Snapshot volumes before deleting them (a snapshot only stores used blocks, and costs half of gp2 per GB anyway). Turn on a Recycle Bin rule with a short retention.
  5. Delete after the window. We use 14 days for dev and 30 for anything near production.
  6. Write down what you saved. It makes next quarter’s cleanup an easy yes.
# Tagging is safe and reversible
aws ec2 create-tags --resources vol-0123456789abcdef0 \
  --tags Key=cleanup-candidate,Value="$(date -u +%Y-%m-%d)" Key=cleanup-reason,Value=unattached-90d

# DESTRUCTIVE: run only after the safety window. The volume is deleted only once its final snapshot completes.
SNAP=$(aws ec2 create-snapshot --volume-id vol-0123456789abcdef0 \
  --description "final copy before cleanup" --query SnapshotId --output text)
aws ec2 wait snapshot-completed --snapshot-ids "$SNAP" \
  && aws ec2 delete-volume --volume-id vol-0123456789abcdef0

# DESTRUCTIVE: releases the address for good
aws ec2 release-address --allocation-id eipalloc-0123456789abcdef0

The waiter gives up after about ten minutes, and the && means the volume survives if it does. For a big volume, rerun the wait line until it returns cleanly.

When not to bother: a single 8 GB volume in a sandbox isn’t worth a meeting. Spend the effort where the numbers are.

FAQ

How do I find unattached EBS volumes in all regions?

Filter describe-volumes on status=available and loop over describe-regions, as shown above. Compute Optimizer’s idle recommendations and Trusted Advisor checks flag them too, but the CLI gives you creation dates and tags in one place.

Am I charged for an Elastic IP attached to a stopped instance?

Yes. Every public IPv4 address costs $0.005 per hour in us-east-1, attached or not, running or stopped. Only BYOIP addresses are exempt.

Does deregistering an AMI delete its snapshots?

Not by default. Pass --delete-associated-snapshots to deregister-image, or delete the snapshots yourself afterwards. Snapshots used by another AMI are kept either way.

Is it safe to delete old EBS snapshots?

Generally yes, because snapshots are incremental and any data needed by newer snapshots is preserved. The risk is deleting the only copy of something. Check the source volume, any AMI links and your retention policy first, and keep a short Recycle Bin rule as a net.

Do stopped EC2 instances cost money?

The instance doesn’t, but its EBS volumes and any Elastic IPs do. Long-stopped instances are usually better as an AMI plus a terminate.

A first pass this week

Run the unattached-volume and unassociated-EIP queries in every region you have, then the orphaned-snapshot pipeline. All three are read-only and take minutes. Put a monthly cost and an owner next to each line before anyone deletes anything; that list is what gets the clean-up approved. For where this fits among the bigger levers, see how to save big on your AWS bill.

The basement fills up again, of course. Kostrix looks for the same leftovers on every scan, from orphaned and aged snapshots to instances stopped for a month or more with their volumes still billing.