Report EC2 Instances by Type, Launch Time and Region

A tall server rack with rows of blinking green and blue status lights in a dim data center

Photo by Tyler on Unsplash

To list EC2 instances in all regions, call DescribeRegions to get the regions enabled for your account, then run DescribeInstances with its paginator in each region and merge the results. Running the regions in parallel with Promise.allSettled keeps it fast and stops one denied region from failing the whole report.

The EC2 instances page shows one region at a time, which is how a forgotten instance in ap-southeast-2 survives for months. This example is for engineers who need a quick, honest inventory: every instance, its type, where it runs, when it was launched and how many hours it has been up. You get a read-only TypeScript script for the AWS SDK for JavaScript v3 that prints a table, totals by instance type and region, and a CSV export for spreadsheets.

It’s part of our library of AWS SDK v3 practical examples. For the conversational version of the same inventory question, see the guide to list AWS resources with natural language from your terminal.

What does this script do?

  1. Find enabled regionsDescribeRegions without AllRegions returns only the regions your account can use, so opt-in regions you never enabled don’t produce errors.
  2. Query every region at onceFor each region it creates an EC2Client and pages through paginateDescribeInstances, filtered to running instances by default. --all-states adds pending, stopping and stopped instances.
  3. Survive partial failuresPromise.allSettled waits for every region and reports which ones failed, instead of rejecting on the first error the way Promise.all would.
  4. Work out running timeRunning hours come from LaunchTime. The CSV also includes firstLaunch, taken from the primary network interface’s attach time, because LaunchTime resets whenever an instance is stopped and started.
  5. SummarizeIt prints a table sorted by region and running time, then counts by instance type and by region. --csv prints every column as CSV instead.

The difference between the two promise helpers is explained in MDN’s reference for Promise.allSettled() and its result objects.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • The @aws-sdk/client-ec2 package.
  • A read-only AWS profile. Nothing in the script changes resources.

Which IAM permissions does it need?

Both actions are read-only and don’t support resource-level permissions, so the policy is short. ReadOnlyAccess covers it too.

ec2-inventory-policy.json

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

An SCP that restricts regions with aws:RequestedRegion can still deny some of them; the script reports those and carries on. The IAM policy generator for TypeScript AWS code is handy if you add more calls, such as CloudWatch metrics per instance.

The full script to list EC2 instances in all regions

ec2-instance-report.ts

// ec2-instance-report.ts
// Lists EC2 instances in every enabled region with type, state, launch time and
// how long each has been running, then summarizes by type and by region. Read-only.
// Flags: --all-states (include stopped instances)  --csv (write CSV to stdout)
import {
  EC2Client,
  DescribeRegionsCommand,
  paginateDescribeInstances,
  type Instance,
} from "@aws-sdk/client-ec2";

const args = process.argv.slice(2);
const ALL_STATES = args.includes("--all-states");
const AS_CSV = args.includes("--csv");
const HOME_REGION = process.env.AWS_REGION ?? "us-east-1";

type Row = {
  region: string;
  zone: string;
  instanceId: string;
  name: string;
  type: string;
  state: string;
  launchTime: string;
  firstLaunch: string;
  runningHours: number;
};

async function enabledRegions(): Promise<string[]> {
  const ec2 = new EC2Client({ region: HOME_REGION });
  const { Regions = [] } = await ec2.send(new DescribeRegionsCommand({}));
  return Regions.map((r) => r.RegionName).filter((r): r is string => !!r).sort();
}

// LaunchTime resets on every stop/start. The primary network interface's attach
// time usually still shows when the instance was first launched.
function firstLaunch(i: Instance): Date | undefined {
  const primary = i.NetworkInterfaces?.find((n) => n.Attachment?.DeviceIndex === 0);
  return primary?.Attachment?.AttachTime ?? i.LaunchTime;
}

async function instancesIn(region: string, now: number): Promise<Row[]> {
  const ec2 = new EC2Client({ region });
  const states = ALL_STATES ? ["pending", "running", "stopping", "stopped"] : ["running"];
  const rows: Row[] = [];
  for await (const page of paginateDescribeInstances(
    { client: ec2 },
    { Filters: [{ Name: "instance-state-name", Values: states }] },
  )) {
    for (const reservation of page.Reservations ?? []) {
      for (const i of reservation.Instances ?? []) {
        const running = i.State?.Name === "running" && i.LaunchTime;
        rows.push({
          region,
          zone: i.Placement?.AvailabilityZone ?? "-",
          instanceId: i.InstanceId ?? "-",
          name: i.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
          type: i.InstanceType ?? "-",
          state: i.State?.Name ?? "-",
          launchTime: i.LaunchTime?.toISOString() ?? "-",
          firstLaunch: firstLaunch(i)?.toISOString() ?? "-",
          runningHours: running ? Math.floor((now - i.LaunchTime!.getTime()) / 3_600_000) : 0,
        });
      }
    }
  }
  return rows;
}

function countBy(rows: Row[], key: "type" | "region"): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const r of rows) counts[r[key]] = (counts[r[key]] ?? 0) + 1;
  return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1]));
}

function toCsv(rows: Row[]): string {
  const headers = Object.keys(rows[0]) as (keyof Row)[];
  const escape = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
  return [headers.join(","), ...rows.map((r) => headers.map((h) => escape(r[h])).join(","))].join("\n");
}

async function main(): Promise<void> {
  const now = Date.now();
  const regions = await enabledRegions();

  // One failing region (an SCP, an opt-in problem) shouldn't sink the whole report.
  const results = await Promise.allSettled(regions.map((r) => instancesIn(r, now)));
  const rows: Row[] = [];
  results.forEach((result, idx) => {
    if (result.status === "fulfilled") rows.push(...result.value);
    else console.error(`Skipped ${regions[idx]}: ${(result.reason as Error).message}`);
  });

  if (rows.length === 0) {
    console.log(`No ${ALL_STATES ? "" : "running "}instances in ${regions.length} regions.`);
    return;
  }
  rows.sort((a, b) => a.region.localeCompare(b.region) || b.runningHours - a.runningHours);

  if (AS_CSV) {
    console.log(toCsv(rows));
    return;
  }
  // firstLaunch is in the CSV; the console table leaves it out to stay readable.
  console.table(rows.map(({ firstLaunch: _omit, ...rest }) => rest));
  console.log("Instances by type:", countBy(rows, "type"));
  console.log("Instances by region:", countBy(rows, "region"));
}

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

Each region gets its own client because an EC2 client is bound to one regional endpoint. Running the regions in parallel is much faster than looping through them one by one; if you hit RequestLimitExceeded, run them in smaller groups.

How do you run it?

Terminal

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

# Running instances in every enabled region
AWS_PROFILE=readonly npx tsx ec2-instance-report.ts

# Include stopped instances and save a spreadsheet
AWS_PROFILE=readonly npx tsx ec2-instance-report.ts --all-states --csv > ec2-inventory.csv

Sample output

Output

┌─────────┬─────────────┬──────────────┬───────────────────────┬────────────────┬───────────────┬───────────┬────────────────────────────┬──────────────┐
│ (index) │ region      │ zone         │ instanceId            │ name           │ type          │ state     │ launchTime                 │ runningHours │
├─────────┼─────────────┼──────────────┼───────────────────────┼────────────────┼───────────────┼───────────┼────────────────────────────┼──────────────┤
│ 0       │ 'eu-west-1' │ 'eu-west-1b' │ 'i-0d4c3b2a19f8e7d6c' │ 'eu-api-1'     │ 'm6i.large'   │ 'running' │ '2026-06-11T08:02:19.000Z' │ 2593         │
│ 1       │ 'us-east-1' │ 'us-east-1a' │ 'i-0a1b2c3d4e5f60718' │ 'web-1'        │ 't3.medium'   │ 'running' │ '2026-03-02T14:21:07.000Z' │ 5011         │
│ 2       │ 'us-east-1' │ 'us-east-1c' │ 'i-0f1e2d3c4b5a69788' │ 'web-2'        │ 't3.medium'   │ 'running' │ '2026-09-20T10:45:51.000Z' │ 167          │
│ 3       │ 'us-east-1' │ 'us-east-1a' │ 'i-09a8b7c6d5e4f3021' │ 'batch-worker' │ 'c6i.2xlarge' │ 'running' │ '2026-09-27T06:30:00.000Z' │ 3            │
└─────────┴─────────────┴──────────────┴───────────────────────┴────────────────┴───────────────┴───────────┴────────────────────────────┴──────────────┘
Instances by type: { 't3.medium': 2, 'm6i.large': 1, 'c6i.2xlarge': 1 }
Instances by region: { 'us-east-1': 3, 'eu-west-1': 1 }

Instance IDs, names and times are illustrative. Region failures, if any, print to stderr as “Skipped <region>: …” before the table, so they don’t end up in a CSV you redirect to a file.

How do you read EC2 running time correctly?

LaunchTime is the last time the instance started, not the day it was created. An instance that’s been stopped and started last week shows a week of running time even if it’s two years old. The firstLaunch column uses the attach time of the network interface at device index 0, which usually stays at the original launch; treat it as a strong hint, not a guarantee.

Running hours are a starting point for cost questions, not a bill. Stopped instances don’t accrue compute charges, but their EBS volumes and any Elastic IPs still do. To act on the report, the example to stop underutilized EC2 instances based on CPU finds idle ones, and the script that lists EC2 Reserved Instances about to expire shows which of these types are still covered by a reservation. Running instances also count toward your per-region On-Demand vCPU quota, so the totals by region double as a rough quota check.

Troubleshooting

  • “Skipped <region>: … not authorized”. An SCP or permissions boundary blocks that region. It may be intentional; if not, the guide to resolve AWS IAM access denied errors shows where to look.
  • Errors in a region you just enabled. Enabling an opt-in region isn’t instant. Wait until the console shows it as enabled, then run the report again.
  • The console shows more instances. By default the script lists running instances only. Add --all-states. Terminated instances are excluded in both modes.
  • No output for a region you use. Check the profile’s account. Each run reports one account; for an organization, run it per member account or with a role you can assume in each.

Ask ChatWithCloud instead

If you only need one region, run ChatWithCloud and ask “List my running EC2 instances with their type and launch time.” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the answer. Each session uses one profile and one region, so for a cross-region inventory the script above is the better tool. How ChatWithCloud turns questions into SDK calls covers the loop, and for a first setup, the steps to connect ChatWithCloud to AWS with a read-only profile are a good start, since changes run without a confirmation step. When an instance in the report is misbehaving, the guide to troubleshoot AWS infrastructure with an AI CLI picks up from there.

Frequently asked questions

How do I see all EC2 instances across all regions?

Call DescribeRegions, then DescribeInstances in each region. The EC2 instances page lists one region at a time, so a script like this one is the quickest way to get every instance with its type and launch time in a single table.

Why did my instance’s launch time change?

LaunchTime updates every time the instance starts after being stopped. Use the primary network interface’s attach time for a closer estimate of the original launch date.

Should I use Promise.all or Promise.allSettled for multi-region calls?

Promise.allSettled. With Promise.all, one region that denies access rejects the whole report. allSettled returns a result for every region so you can keep the good ones and log the rest.

Can I export the EC2 report to Excel?

Yes. Run the script with --csv and redirect the output to a file; spreadsheets open it directly.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud