# Idle EC2 Instance

> Stopping an EC2 instance ends the compute charge, but its attached EBS volumes and any associated Elastic IP keep billing every month. ZopNight flags instances stopped for 30 days or more where those attachments still cost money, and prices the exact residual rather than estimating it.

Source: https://zop.dev/integrations/aws/recommendations/idle-ec2-instance
Updated: 2026-08-19

---

## Why a stopped instance still costs money

Stopping an instance releases the CPU and memory, and [the instance-hour charge](https://aws.amazon.com/ec2/pricing/)
ends the moment the state changes. Everything attached to it keeps billing. A 500 GB gp3 root volume costs the
same whether the instance runs or has been off since last quarter, and since February 2024 AWS
charges for every allocated public IPv4 address, so an Elastic IP left associated with a
stopped instance bills around $3.60 a month for nothing.

That is the gap this rule closes. It does not tell you an instance looks under-used. It tells
you an instance is already off, has been for a month, and is still generating a bill.

## How do I find these myself?

List the stopped instances and read the transition reason, which carries the timestamp:

```bash
aws ec2 describe-instances \
  --filters Name=instance-state-name,Values=stopped \
  --query 'Reservations[].Instances[].[InstanceId,StateTransitionReason]'
```

Then check what is still attached to any instance older than 30 days:

```bash
aws ec2 describe-volumes \
  --filters Name=attachment.instance-id,Values=i-0123456789abcdef0 \
  --query 'Volumes[].[VolumeId,Size,VolumeType]'
```

Every volume returned is billing at full rate. Repeat with `describe-addresses` for the
Elastic IP.

## What has to be true before it fires

Three gates, all of which must pass:

1. The instance status is `stopped`.
2. The stop is provably at least 30 days old, established from recorded state history, or from an
   `orphanSince` timestamp the discoverer parses out of the instance's
   `StateTransitionReason` field.
3. At least one attached EBS volume or associated Elastic IP resolves to a real price.

## When it deliberately says nothing

The rule abstains more often than it fires, which is the point.

`CPUUtilization` is one of [the series EC2 sends to CloudWatch only while an instance runs](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/viewing_metrics_with_cloudwatch.html). So if any CPU datapoint
exists inside the 30-day window, the "stopped" snapshot must be stale: someone started the
instance after discovery ran. The rule returns nothing rather than reporting a saving on
a machine that is currently serving traffic.

It also abstains when an instance was stopped 45 days ago but restarted 3 days ago: the most
recent start is tracked separately, so a restart inside the window disqualifies it.

If neither an EBS volume nor an Elastic IP can be priced, the rule emits nothing at all. It
never reports a $0 finding, and it never invents a compute rate for a box that is not running.

One limitation stated plainly: a batch host with an on/off cadence longer than the 30-day
lookback shows no recent CPU and can still be flagged. Cadences longer than the window are
out of scope.

## What the saving actually is

The reported figure is the sum of the residual attachments, not a percentage of the instance's
old bill:

```text
SavingsUSD = Σ attached EBS volumes + Σ associated Elastic IPs
OptimizedCostUSD = 0
```

Terminating the instance, deleting the volumes and releasing the address reclaims all of it.

## How to fix it

1. Confirm nothing depends on the instance: check DNS records, load balancer target groups
   and any automation that starts it on a schedule.
2. Take an AMI if the disk contents still matter. This is the step people skip and regret.
3. Terminate the instance.
4. Delete the detached EBS volumes. Termination does **not** remove volumes whose
   `DeleteOnTermination` flag is false, which is common for data volumes.
5. Release the Elastic IP. An allocated address that is not associated with a running instance
   keeps billing.
