How to find and stop idle EC2 instances
Find EC2 instances (and Compute Engine VMs) that do nothing all day, check they’re really idle, and stop them without losing anything.
Contents
In short
- An instance whose CPU stays under 5% every day for two weeks is very likely idle.
- Check network and memory before you stop it, and ask who owns it.
- A stopped instance stops billing for compute, but its volumes and Elastic IPs keep billing.
An idle instance bills the full hourly rate to do nothing. It’s usually a test box someone forgot, a migration’s old half, or a service whose traffic moved elsewhere. Finding them takes two weeks of CPU data and a few checks. Stopping them takes one command.
The rule this guide follows: an instance whose CPU peaked under 5% every day for two weeks, and averaged under 2%, is very likely idle. That’s strict on purpose. A real workload almost always crosses 5% at some point in a fortnight.
List what’s running
Start with every running instance in every Region. Idle instances hide in Regions nobody looks at:
for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
echo "== $r"
aws ec2 describe-instances --region "$r" \
--filters Name=instance-state-name,Values=running \
--query 'Reservations[].Instances[].[InstanceId, InstanceType, LaunchTime,
Tags[?Key==`Name`]|[0].Value]' \
--output table
done
Check two weeks of CPU
For each candidate, pull the daily maximum and average CPU for the last 14 days from CloudWatch. A period of 86,400 seconds gives one row per day:
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 --metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=i-0123456789abcdef0 \
--start-time 2026-09-27T00:00:00Z --end-time 2026-10-11T00:00:00Z \
--period 86400 --statistics Maximum Average \
--query 'sort_by(Datapoints, &Timestamp)[].[Timestamp, Maximum, Average]' \
--output table
If every daily maximum is under 5% and the average is under 2%, the instance is a candidate. If it has fewer than 14 days of data, it’s too new to judge: wait.
Rule out the false positives
Low CPU doesn’t always mean unused. Before you stop anything, check three things.
Network. A proxy, a bastion host or a small cache can serve real traffic at almost no CPU. Pull NetworkIn and NetworkOut the same way as CPU. Steady traffic means something depends on it.
Memory. CloudWatch doesn’t see memory unless the CloudWatch agent is installed. An in-memory store can sit at 1% CPU and still be in use. If the agent is there, check mem_used_percent in the CWAgent namespace.
Ownership. Look at the tags first. If there’s no owner tag, CloudTrail’s event history can show who launched it, if the launch was recent enough to be there:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=ResourceName,AttributeValue=i-0123456789abcdef0 \
--query 'Events[].[EventTime, EventName, Username]' \
--output table
Ask before you act. A message to the owner with the CPU numbers settles most cases in a day.
Stop it, or delete it
Stopping is the safe first move. It’s reversible, and the instance comes back with the same volumes:
aws ec2 stop-instances --instance-ids i-0123456789abcdef0
Stopping doesn’t make it free. Here’s what keeps billing, as AWS documents it (AWS docs):
| While stopped | Billed? |
|---|---|
| Instance hours and data transfer | No |
| EBS volumes attached to it | Yes, every GiB |
| Elastic IPs associated with it | Yes |
| Its auto-assigned public IPv4 address | No, it’s released on stop |
Each start bills at least one minute. Anything on instance store volumes is lost when the instance stops, so check for those before stopping a storage-heavy type.
If nobody claims the instance after a few weeks stopped, delete it. Take an image first if there’s any doubt, then terminate:
AMI=$(aws ec2 create-image --instance-id i-0123456789abcdef0 \
--name "before-delete-i-0123456789abcdef0" --no-reboot \
--query ImageId --output text) &&
aws ec2 wait image-available --image-ids "$AMI" &&
aws ec2 terminate-instances --instance-ids i-0123456789abcdef0
The && chain terminates the instance only once the image is available. If any step fails, nothing is deleted.
Terminating deletes volumes only where DeleteOnTermination is set. Check for leftovers afterwards with the unattached EBS volumes guide, release any Elastic IP it held (unused public IPs), and deregister the image once it’s no longer needed (unused AMIs).
On Google Cloud
The same rule applies to Compute Engine VMs. List running VMs with their labels:
gcloud compute instances list \
--filter="status=RUNNING" \
--format="table(name, zone.basename(), machineType.basename(), labels.env)"
CPU lives in Cloud Monitoring as compute.googleapis.com/instance/cpu/utilization; chart it in Metrics Explorer with a daily max aligner. Google also runs an idle VM recommender, which gives you a second opinion:
gcloud recommender recommendations list \
--project=my-project --location=us-central1-a \
--recommender=google.compute.instance.IdleResourceRecommender
Stop a VM with:
gcloud compute instances stop my-vm --zone=us-central1-a
As on AWS, a stopped VM still bills its persistent disks. A static external IP attached to a stopped VM still counts as in use, so it keeps billing too (Google Cloud pricing).
Keep it from coming back
Idle instances come back when launching is easy and nobody owns what was launched. Three habits stop most of it:
- Require an owner tag. A tag policy or a launch template that sets
ownerandenvmakes the next cleanup a message instead of an investigation. - Schedule non-production. A dev box that only runs on weekdays can’t sit idle all weekend. See scheduling dev and staging.
- Look every two weeks. The rule needs two weeks of data anyway. Omni FinOps flags idle instances on every refresh with the stop command attached; by hand, a fortnightly pass over the CPU query above does the same job.
If CPU isn’t near zero but never gets high either, the instance isn’t idle, it’s oversized. That’s the rightsizing guide.
Mateo Vargas writes Omni FinOps’ cost guides, from the same rules the product runs on every refresh. Prices are list prices in us-east-1 or us-central1 as of October 11, 2026; check your region’s pricing page.