Find EC2 Instances Stopped for Weeks and Still Costing You

Dark server rack with its front panel lights switched off in a data center aisle

Photo by imgix on Unsplash

To find stopped EC2 instances that still cost you money, call DescribeInstances with the filter instance-state-name = stopped, read the stop date from each instance’s StateTransitionReason, and add up the EBS volumes and Elastic IP addresses still attached. Stopped instances don’t bill for compute, but their storage and public IPv4 addresses do. The script below reports all of it and changes nothing.

Stopping an instance feels like turning it off, so it drops out of everyone’s mind. The volumes behind it keep billing every month, and so does any Elastic IP address attached to it. A few stopped build servers, a proof of concept with a 500 GB data volume and a Windows test box can add up to real money a year later. This example is for engineers who want to find stopped EC2 instances that have sat idle for weeks and put a monthly figure next to each one. You’ll get a TypeScript script for the AWS SDK for JavaScript v3.

It complements the example to detect and stop underutilized EC2 instances by CPU, which finds running instances worth stopping. This one picks up where that leaves off: instances already stopped, and what they still cost. Both are part of our AWS cost cleanup examples with runnable scripts, along with the script to find idle RDS instances with no connections, which does the same job for databases.

What does a stopped EC2 instance still cost?

Instance usage isn’t billed while an instance is stopped, but its EBS volumes are, and an Elastic IP address stays associated with it. Its auto-assigned public IPv4 address is released on stop, so that one stops billing. Prices in US East (N. Virginia), as of September 2026, from the Amazon EBS pricing page and the Amazon VPC pricing page for public IPv4:

Charge while stopped Price Example per month
gp3 volume storage $0.08 per GB-month 1,100 GB × $0.08 = $88.00
gp2 volume storage $0.10 per GB-month 500 GB × $0.10 = $50.00
io1 / io2 storage $0.125 per GB-month, plus provisioned IOPS Depends on IOPS
Elastic IP (public IPv4) $0.005 per hour 730 × $0.005 = $3.65
EBS snapshot (standard) $0.05 per GB-month 300 GB × $0.05 = $15.00

So a stopped m5.xlarge with 1,100 GB of gp3 storage and an Elastic IP costs about $91.65 a month, or roughly $1,100 a year, while doing nothing. Reserved Instances also keep billing until the end of their term whether or not an instance runs; the script to find EC2 Reserved Instances about to expire shows what you’re committed to.

How does the script know when an instance was stopped?

EC2 doesn’t return a stop timestamp as a field. The closest thing is StateTransitionReason, which the API reference describes only as the reason for the most recent state transition, possibly an empty string. For instances stopped through the console or API it usually reads User initiated (2026-08-03 14:12:09 GMT), and the script parses the date from that.

That format isn’t a documented contract, and instances stopped by an AWS service can show the reason without a date. The script reports those as unknown and includes them rather than guessing. If you need a hard date, CloudTrail event history keeps StopInstances events for 90 days. LaunchTime is no help here: it’s the last time the instance started, not when it stopped.

What does the script do?

  1. Lists stopped instancespaginateDescribeInstances with instance-state-name = stopped. Hibernated instances are included, since they’re also stopped.
  2. Parses the stop dateFrom StateTransitionReason, skipping instances stopped fewer than --days days ago (default 14).
  3. Prices attached volumesJoins each instance’s BlockDeviceMappings to DescribeVolumes for size and type.
  4. Counts Elastic IPsDescribeAddresses once, matched on InstanceId.
  5. ReportsA table sorted by monthly cost, plus the total. Nothing is stopped, terminated or deleted.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • The @aws-sdk/client-ec2 package.
  • A profile with a default region, or AWS_REGION set. The script covers one region per run.

Which IAM permissions does it need?

Only three describe actions, none of which support resource-level restrictions:

stopped-ec2-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportStoppedInstances",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeVolumes",
        "ec2:DescribeAddresses"
      ],
      "Resource": "*"
    }
  ]
}

AWS’s ReadOnlyAccess managed policy covers them. If you extend the script, the free IAM policy generator for TypeScript SDK code lists the new actions for you.

The full script to find stopped EC2 instances

find-long-stopped-ec2-instances.ts

// find-long-stopped-ec2-instances.ts
// Reports EC2 instances in one region that have been stopped for at least --days days,
// with the EBS volumes and Elastic IPs that keep billing while they sit there. Read-only.
// Usage: npx tsx find-long-stopped-ec2-instances.ts [--days 14]
import {
  EC2Client,
  paginateDescribeInstances,
  paginateDescribeVolumes,
  DescribeAddressesCommand,
  type Instance,
  type Volume,
} from "@aws-sdk/client-ec2";

// USD per GB-month, us-east-1, as of September 2026 (AWS Price List API).
const EBS_PER_GB_MONTH: Record<string, number> = {
  gp3: 0.08, gp2: 0.1, io1: 0.125, io2: 0.125, st1: 0.045, sc1: 0.015, standard: 0.05,
};
const IPV4_PER_HOUR = 0.005; // every public IPv4 address, including Elastic IPs
const HOURS_PER_MONTH = 730;

function arg(name: string): string | undefined {
  const i = process.argv.indexOf(name);
  return i === -1 ? undefined : process.argv[i + 1];
}

const ec2 = new EC2Client({}); // region from AWS_REGION or your profile

// StateTransitionReason usually reads "User initiated (2026-08-03 14:12:09 GMT)".
// The format isn't a documented contract, so a missing date is reported as unknown.
function stoppedAt(i: Instance): Date | undefined {
  const m = /\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) GMT\)/.exec(i.StateTransitionReason ?? "");
  return m ? new Date(m[1].replace(" ", "T") + "Z") : undefined;
}

async function main(): Promise<void> {
  const minDays = Number(arg("--days") ?? 14);

  const stopped: Instance[] = [];
  for await (const page of paginateDescribeInstances(
    { client: ec2 },
    { Filters: [{ Name: "instance-state-name", Values: ["stopped"] }] },
  )) {
    for (const r of page.Reservations ?? []) stopped.push(...(r.Instances ?? []));
  }
  if (stopped.length === 0) {
    console.log("No stopped instances in this region.");
    return;
  }

  // Every attached volume in the region, looked up by ID below.
  const volumes = new Map<string, Volume>();
  for await (const page of paginateDescribeVolumes({ client: ec2 }, { Filters: [{ Name: "status", Values: ["in-use"] }] })) {
    for (const v of page.Volumes ?? []) if (v.VolumeId) volumes.set(v.VolumeId, v);
  }

  // Elastic IPs associated with the stopped instances.
  const eips = await ec2.send(new DescribeAddressesCommand({}));
  const eipCount = new Map<string, number>();
  for (const a of eips.Addresses ?? []) {
    if (a.InstanceId) eipCount.set(a.InstanceId, (eipCount.get(a.InstanceId) ?? 0) + 1);
  }

  const now = Date.now();
  const rows = [];
  let total = 0;
  for (const inst of stopped) {
    const id = inst.InstanceId ?? "";
    const since = stoppedAt(inst);
    const days = since ? Math.floor((now - since.getTime()) / 86_400_000) : undefined;
    if (days !== undefined && days < minDays) continue;

    const attachedIds = (inst.BlockDeviceMappings ?? []).map((b) => b.Ebs?.VolumeId ?? "");
    const vols = attachedIds.map((v) => volumes.get(v)).filter((v): v is Volume => v !== undefined);
    const gb = vols.reduce((s, v) => s + (v.Size ?? 0), 0);
    const ebsCost = vols.reduce((s, v) => s + (v.Size ?? 0) * (EBS_PER_GB_MONTH[v.VolumeType ?? ""] ?? 0), 0);
    const ipCost = (eipCount.get(id) ?? 0) * IPV4_PER_HOUR * HOURS_PER_MONTH;
    total += ebsCost + ipCost;

    rows.push({
      Instance: id,
      Name: inst.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
      Type: inst.InstanceType ?? "",
      "Stopped since": since ? since.toISOString().slice(0, 10) : "unknown",
      Days: days ?? "?",
      Volumes: vols.length,
      "EBS GB": gb,
      EIPs: eipCount.get(id) ?? 0,
      "EBS $/mo": ebsCost.toFixed(2),
      "IPv4 $/mo": ipCost.toFixed(2),
    });
  }

  rows.sort((a, b) => Number(b["EBS $/mo"]) + Number(b["IPv4 $/mo"]) - Number(a["EBS $/mo"]) - Number(a["IPv4 $/mo"]));
  console.table(rows);
  console.log(`${rows.length} instances stopped ${minDays}+ days (or unknown): about $${total.toFixed(2)}/month in storage and IPv4 charges.`);
  console.log("Excludes provisioned IOPS/throughput and snapshots. Report only: nothing was changed.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

The storage figure covers GB-month charges only. Provisioned IOPS on io1/io2 and extra gp3 IOPS or throughput add to it, and snapshots taken earlier are billed separately.

How do you run it?

Terminal

npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript

# Instances stopped 14 days or more
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-long-stopped-ec2-instances.ts

# Only those stopped for 60 days or more
AWS_PROFILE=readonly AWS_REGION=eu-west-1 npx tsx find-long-stopped-ec2-instances.ts --days 60

Sample output

Output

┌─────────┬───────────────────────┬───────────────┬─────────────┬───────────────┬──────┬─────────┬────────┬──────┬──────────┬───────────┐
│ (index) │ Instance              │ Name          │ Type        │ Stopped since │ Days │ Volumes │ EBS GB │ EIPs │ EBS $/mo │ IPv4 $/mo │
├─────────┼───────────────────────┼───────────────┼─────────────┼───────────────┼──────┼─────────┼────────┼──────┼──────────┼───────────┤
│ 0       │ 'i-0a1b2c3d4e5f60718' │ 'old-jenkins' │ 'm5.xlarge' │ '2026-05-02'  │ 148  │ 2       │ 1100   │ 1    │ '88.00'  │ '3.65'    │
│ 1       │ 'i-0b2c3d4e5f6071829' │ 'poc-ml-box'  │ 'g5.xlarge' │ '2026-07-19'  │ 70   │ 1       │ 500    │ 0    │ '50.00'  │ '0.00'    │
│ 2       │ 'i-0c3d4e5f607182930' │ 'win-test'    │ 't3.large'  │ 'unknown'     │ '?'  │ 1       │ 100    │ 1    │ '8.00'   │ '3.65'    │
└─────────┴───────────────────────┴───────────────┴─────────────┴───────────────┴──────┴─────────┴────────┴──────┴──────────┴───────────┘
3 instances stopped 14+ days (or unknown): about $153.30/month in storage and IPv4 charges.
Excludes provisioned IOPS/throughput and snapshots. Report only: nothing was changed.

IDs and figures are illustrative. old-jenkins has two gp3 volumes totaling 1,100 GB (1,100 × $0.08 = $88.00) and one Elastic IP ($3.65). win-test has no date in its transition reason, so it’s listed as unknown for you to check.

Should you snapshot and terminate a long-stopped instance?

If nobody has started an instance in months, keeping its volumes online is usually the most expensive way to keep its data. A cheaper path, once the owner agrees:

  1. Create an AMICreateImage snapshots every attached EBS volume and records how to rebuild the instance. Snapshots store a full copy of the data first, then only changed blocks, at $0.05 per GB-month.
  2. Terminate the instanceThe root volume is deleted by default; other volumes are kept unless their DeleteOnTermination flag is set.
  3. Clean up what’s leftKept data volumes become unattached, and the Elastic IP stays allocated to your account.

That last step is where savings leak away. The script to find and tag unattached EBS volumes catches the leftover volumes, and the one to find and release unassociated Elastic IP addresses catches the addresses. Months later, the AMI and snapshot cleanup in the related examples keeps the backups from piling up. If you’d rather keep the instance, convert its EBS gp2 volumes to gp3 to cut storage by 20% per GB. Once the instance is terminated, the security groups it used may have nothing else attached; the script to find unused security groups in your AWS account lists them.

Starting a stopped instance again is always an option, and many are stopped for good reasons: a quarterly job, a disaster recovery standby, a license server. That’s why the script only reports.

Troubleshooting

  • Many instances show unknown. Their transition reason has no date. Look up StopInstances in CloudTrail event history for the last 90 days, or check the tags for an owner.
  • The cost looks low for a big instance. The report prices storage, not the instance type. A stopped p4d costs the same as a stopped t3.micro with the same volumes.
  • UnauthorizedOperation. Add the three describe actions above to the profile’s policy.
  • Nothing listed. Check the region. Stopped instances elsewhere need another run with a different AWS_REGION.

Ask ChatWithCloud instead

ChatWithCloud can also find stopped EC2 instances. It answers “Which EC2 instances have been stopped for more than a month, and how big are their volumes?” by writing AWS SDK for JavaScript v2 code, running it on your machine with your AWS profile and explaining the result. The guide to ask AI why your AWS bill increased shows the follow-up questions that tie EBS charges back to instances. Changes run without a confirmation step, so ask for the report and terminate instances yourself. Connect ChatWithCloud with a read-only AWS profile to keep it that way.

Frequently asked questions

Am I charged for a stopped EC2 instance?

Not for instance usage. You’re charged for its EBS volumes and for any Elastic IP address associated with it, and Reserved Instances keep billing either way.

How do I see when an EC2 instance was stopped?

Check StateTransitionReason in DescribeInstances output, which usually includes the date for user-initiated stops, or search CloudTrail event history for StopInstances within the last 90 days.

What happens to the EBS volumes when I terminate a stopped instance?

By default the root volume is deleted and any other attached volumes are kept. Check each volume’s DeleteOnTermination setting, and snapshot anything you need first.

Does a stopped instance keep its public IP address?

It keeps an Elastic IP address, which keeps billing. An auto-assigned public IPv4 address is released, and the instance gets a new one when it starts.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud