Find and Delete Unused Elastic Beanstalk Environments

A mostly empty server rack with a few blinking units in a dim data center aisle

Photo by panumas nikhomkhai on Pexels

To delete an unused Elastic Beanstalk environment safely, first prove it’s unused: list environments with DescribeEnvironments, find each one’s load balancer or worker queue with DescribeEnvironmentResources, and sum 14 days of RequestCount in CloudWatch. For environments with zero traffic, save the configuration with CreateConfigurationTemplate, then call TerminateEnvironment.

Elastic Beanstalk makes it easy to spin up a staging copy, a demo for a partner or a worker for a one-off job. It doesn’t remind you they exist. Each environment keeps its EC2 instances, its load balancer and often a queue or database running until someone terminates it, and Elastic Beanstalk itself adds no charge that would show up as a separate line to question.

This example is for platform engineers and team leads who want to find and delete unused Elastic Beanstalk environment copies without losing the ability to bring them back. The script reports every environment’s traffic, instances and monthly cost, lists application versions nothing uses, and only terminates the environments you name with --apply --env.

What does an unused Elastic Beanstalk environment cost?

AWS’s Elastic Beanstalk pricing page says there’s no additional charge for Elastic Beanstalk; you pay for the resources it creates. For a typical environment that’s the instances plus the load balancer. As of September 2026, the AWS Price List shows these us-east-1 On-Demand rates (EC2 list published 25 September 2026, Elastic Load Balancing list published 11 September 2026):

Resource Hourly Per 730-hour month
t3.micro (Linux) $0.0104 $7.59
t3.small (Linux) $0.0208 $15.18
t3.medium (Linux) $0.0416 $30.37
m5.large (Linux) $0.096 $70.08
Application Load Balancer $0.0225 + LCUs $16.43 + LCUs
Classic Load Balancer $0.025 + data processed $18.25 + data

Worked example: an old staging environment with one t3.small behind an Application Load Balancer costs ($0.0208 + $0.0225) × 730 = $31.61 a month before LCUs, EBS volumes and data transfer. A partner demo on an m5.large behind a Classic Load Balancer costs ($0.096 + $0.025) × 730 = $88.33. The script’s estimate is a floor: attached databases, NAT gateways and CloudWatch logs aren’t included.

How do you tell an Elastic Beanstalk environment is unused?

Health and status don’t answer this: a forgotten environment is often Green and Ready. Traffic does. The script picks one metric per environment from what DescribeEnvironmentResources returns:

  • Application Load Balancer: RequestCount in AWS/ApplicationELB with the LoadBalancer dimension (app/name/id, taken from the ARN).
  • Network Load Balancer: NewFlowCount in AWS/NetworkELB.
  • Classic Load Balancer: RequestCount in AWS/ELB with LoadBalancerName.
  • Worker tier: NumberOfMessagesSent in AWS/SQS for the environment’s queue. Zero means nothing handed the worker any work.

Single-instance environments have no load balancer, so the script labels them check instead of guessing from instance network traffic, which includes updates and agents. Environments created within the window show too new to judge if they have no traffic yet. The approach mirrors the script to find unused load balancers in AWS, which covers load balancers created outside Elastic Beanstalk.

What does the script do?

  1. Lists environmentsDescribeEnvironments with IncludeDeleted: false, following NextToken.
  2. Reads resourcesDescribeEnvironmentResources returns instances, load balancers and queues; DescribeInstances gives instance types.
  3. Reads trafficOne GetMetricData call per environment with daily sums over --days (default 14).
  4. Checks application versionsDescribeApplicationVersions per application, compared with the versions environments run.
  5. Saves, then terminates on requestFor each environment named in --env and flagged IDLE: CreateConfigurationTemplate, then TerminateEnvironment.

Prerequisites

  • Node.js 18 or later with tsx, plus @aws-sdk/client-elastic-beanstalk, @aws-sdk/client-ec2 and @aws-sdk/client-cloudwatch.
  • A read-only profile for the report. Terminating needs much broader rights, covered below.
  • Owners to ask. The script to find untagged AWS resources shows which environments nobody claimed.

Which IAM permissions does it need?

unused-beanstalk-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportOnEnvironments",
      "Effect": "Allow",
      "Action": [
        "elasticbeanstalk:DescribeEnvironments",
        "elasticbeanstalk:DescribeEnvironmentResources",
        "elasticbeanstalk:DescribeApplicationVersions",
        "ec2:DescribeInstances",
        "cloudwatch:GetMetricData"
      ],
      "Resource": "*"
    }
  ]
}

If a Region returns access errors, AWS’s AWSElasticBeanstalkReadOnly managed policy grants read access to Elastic Beanstalk and to the other resources its console retrieves. For --apply you also need elasticbeanstalk:CreateConfigurationTemplate and elasticbeanstalk:TerminateEnvironment, plus permission to delete what the environment created. AWS’s AdministratorAccess-AWSElasticBeanstalk policy is the documented way to grant that. The IAM policy generator for TypeScript code lists what the script’s own calls need.

The script to find unused Elastic Beanstalk environments

find-unused-elastic-beanstalk-environments.ts

// find-unused-elastic-beanstalk-environments.ts
// Lists Elastic Beanstalk environments with their instances, load balancer or queue, traffic over the last
// N days and an estimated monthly cost, plus application versions no environment uses.
// Report only by default. --apply --env name1,name2 saves each named IDLE environment's configuration
// (CreateConfigurationTemplate) and then terminates it.
// Usage: npx tsx find-unused-elastic-beanstalk-environments.ts [--regions us-east-1] [--days 14]
//        [--csv eb.csv] [--apply --env old-staging]
import { writeFileSync } from "node:fs";
import {
  ElasticBeanstalkClient,
  CreateConfigurationTemplateCommand,
  DescribeApplicationVersionsCommand,
  DescribeEnvironmentResourcesCommand,
  DescribeEnvironmentsCommand,
  TerminateEnvironmentCommand,
  type ApplicationVersionDescription,
  type EnvironmentDescription,
} from "@aws-sdk/client-elastic-beanstalk";
import { EC2Client, paginateDescribeInstances } from "@aws-sdk/client-ec2";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Number(flag("--days") ?? 14);
const csvPath = flag("--csv");
const apply = args.includes("--apply");
const toTerminate = new Set((flag("--env") ?? "").split(",").map((e) => e.trim()).filter(Boolean));

// us-east-1 USD from the AWS Price List: EC2 Linux On-Demand (published 25 September 2026) and
// Elastic Load Balancing (published 11 September 2026). LCUs, data transfer and EBS are extra.
const INSTANCE_HOURLY: Record<string, number> = {
  "t3.micro": 0.0104, "t3.small": 0.0208, "t3.medium": 0.0416,
  "t4g.small": 0.0168, "t4g.medium": 0.0336, "m5.large": 0.096, "m7g.large": 0.0816,
};
const LB_HOURLY = { application: 0.0225, network: 0.0225, classic: 0.025 };
const HOURS = 730;

interface Row {
  Region: string;
  Application: string;
  Environment: string;
  Tier: string;
  Status: string;
  Health: string;
  Updated: string;
  Instances: string;
  Traffic: string;
  PerMonth: string;
  Verdict: string;
}

interface Target { kind: keyof typeof LB_HOURLY | "queue" | "none"; query?: MetricDataQuery; label: string }

async function environments(eb: ElasticBeanstalkClient): Promise<EnvironmentDescription[]> {
  const envs: EnvironmentDescription[] = [];
  let NextToken: string | undefined;
  do {
    const page = await eb.send(new DescribeEnvironmentsCommand({ IncludeDeleted: false, NextToken }));
    envs.push(...(page.Environments ?? []));
    NextToken = page.NextToken;
  } while (NextToken);
  return envs;
}

async function versions(eb: ElasticBeanstalkClient, app: string): Promise<ApplicationVersionDescription[]> {
  const list: ApplicationVersionDescription[] = [];
  let NextToken: string | undefined;
  do {
    const page = await eb.send(new DescribeApplicationVersionsCommand({ ApplicationName: app, NextToken }));
    list.push(...(page.ApplicationVersions ?? []));
    NextToken = page.NextToken;
  } while (NextToken);
  return list;
}

/** Picks the metric that shows real use: requests on the load balancer, or messages sent to a worker queue. */
function trafficTarget(lbName: string | undefined, queueUrl: string | undefined): Target {
  const metric = (Namespace: string, MetricName: string, Name: string, Value: string): MetricDataQuery => ({
    Id: "traffic",
    MetricStat: { Metric: { Namespace, MetricName, Dimensions: [{ Name, Value }] }, Period: 86_400, Stat: "Sum" },
  });
  if (lbName?.startsWith("arn:")) {
    const dim = lbName.split(":loadbalancer/")[1] ?? ""; // app/name/id or net/name/id
    return dim.startsWith("net/")
      ? { kind: "network", query: metric("AWS/NetworkELB", "NewFlowCount", "LoadBalancer", dim), label: "flows" }
      : { kind: "application", query: metric("AWS/ApplicationELB", "RequestCount", "LoadBalancer", dim), label: "requests" };
  }
  if (lbName) return { kind: "classic", query: metric("AWS/ELB", "RequestCount", "LoadBalancerName", lbName), label: "requests" };
  if (queueUrl) {
    const queueName = queueUrl.split("/").pop() ?? "";
    return { kind: "queue", query: metric("AWS/SQS", "NumberOfMessagesSent", "QueueName", queueName), label: "messages" };
  }
  return { kind: "none", label: "" };
}

async function traffic(cw: CloudWatchClient, query: MetricDataQuery): Promise<number> {
  const end = new Date();
  const out = await cw.send(new GetMetricDataCommand({
    StartTime: new Date(end.getTime() - days * 86_400_000),
    EndTime: end,
    MetricDataQueries: [query],
  }));
  return (out.MetricDataResults ?? []).flatMap((r) => r.Values ?? []).reduce((a, b) => a + b, 0);
}

async function instanceTypes(ec2: EC2Client, ids: string[]): Promise<string[]> {
  if (ids.length === 0) return [];
  const types: string[] = [];
  for await (const page of paginateDescribeInstances({ client: ec2 }, { InstanceIds: ids })) {
    for (const r of page.Reservations ?? []) for (const i of r.Instances ?? []) types.push(i.InstanceType ?? "unknown");
  }
  return types;
}

async function scanRegion(
  region: string,
  envIds: Map<string, { region: string; env: EnvironmentDescription }>,
  notes: string[],
): Promise<Row[]> {
  const eb = new ElasticBeanstalkClient({ region });
  const ec2 = new EC2Client({ region });
  const cw = new CloudWatchClient({ region });
  const rows: Row[] = [];
  const envs = await environments(eb);
  const deployed = new Map<string, Set<string>>(); // application -> version labels in use

  for (const env of envs) {
    const app = env.ApplicationName ?? "";
    if (env.VersionLabel) deployed.set(app, (deployed.get(app) ?? new Set<string>()).add(env.VersionLabel));
    const res = (await eb.send(new DescribeEnvironmentResourcesCommand({ EnvironmentId: env.EnvironmentId }))).EnvironmentResources;
    const types = await instanceTypes(ec2, (res?.Instances ?? []).map((i) => i.Id ?? "").filter(Boolean));
    const target = trafficTarget(res?.LoadBalancers?.[0]?.Name, res?.Queues?.[0]?.URL);
    const count = target.query ? await traffic(cw, target.query) : undefined;

    const unpriced = types.filter((t) => INSTANCE_HOURLY[t] === undefined).length;
    const hourly = types.reduce((s, t) => s + (INSTANCE_HOURLY[t] ?? 0), 0)
      + (target.kind in LB_HOURLY ? LB_HOURLY[target.kind as keyof typeof LB_HOURLY] : 0);
    const updated = env.DateUpdated ?? env.DateCreated;
    const ageDays = env.DateCreated ? (Date.now() - env.DateCreated.getTime()) / 86_400_000 : days;

    let verdict = "in use";
    if (env.Status !== "Ready") verdict = `check: status ${env.Status ?? "unknown"}`;
    else if (count === undefined) verdict = "check: no load balancer or queue metric";
    else if (count === 0) verdict = ageDays < days ? "too new to judge" : `IDLE: no ${target.label}`;

    rows.push({
      Region: region,
      Application: app,
      Environment: env.EnvironmentName ?? "",
      Tier: env.Tier?.Name ?? "",
      Status: env.Status ?? "",
      Health: env.Health ?? "",
      Updated: updated ? updated.toISOString().slice(0, 10) : "",
      Instances: types.length ? types.join(", ") : "0",
      Traffic: count === undefined ? "-" : `${Math.round(count)} ${target.label}`,
      PerMonth: `$${(hourly * HOURS).toFixed(2)}${unpriced ? ` + ${unpriced} unpriced` : ""}`,
      Verdict: verdict,
    });
    if (env.EnvironmentName) envIds.set(env.EnvironmentName, { region, env });
  }

  // Application versions that no running environment uses.
  for (const app of new Set(envs.map((e) => e.ApplicationName ?? "").filter(Boolean))) {
    const all = await versions(eb, app);
    const unused = all.filter((v) => !deployed.get(app)?.has(v.VersionLabel ?? ""));
    const oldest = unused.map((v) => v.DateCreated?.toISOString().slice(0, 10) ?? "").sort()[0] ?? "-";
    notes.push(`${region} ${app}: ${all.length} application versions, ${unused.length} not deployed (oldest ${oldest})`);
  }
  return rows;
}

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

async function main(): Promise<void> {
  if (!Number.isInteger(days) || days < 1 || days > 455) throw new Error("--days must be a whole number from 1 to 455");
  if (apply && toTerminate.size === 0) throw new Error("--apply needs --env with the environment names to terminate");
  const rows: Row[] = [];
  const envIds = new Map<string, { region: string; env: EnvironmentDescription }>();
  const notes: string[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region, envIds, notes)));
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
  if (rows.length === 0) {
    console.log(`No Elastic Beanstalk environments in ${regions.join(", ")}.`);
    return;
  }
  console.table(rows);
  const idle = rows.filter((r) => r.Verdict.startsWith("IDLE"));
  const monthly = idle.reduce((s, r) => s + Number(r.PerMonth.split(" ")[0].replace("$", "")), 0);
  for (const note of notes) console.log(note);
  console.log(`${idle.length} of ${rows.length} environments idle for ${days} days: at least $${monthly.toFixed(2)} a month (us-east-1 prices).`);
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  if (!apply) return;
  const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
  for (const name of toTerminate) {
    const found = envIds.get(name);
    if (!found || !idle.some((r) => r.Environment === name)) {
      console.log(`${name}: not flagged IDLE, skipped`);
      continue;
    }
    const eb = new ElasticBeanstalkClient({ region: found.region });
    const template = `${name}-saved-${stamp}`.slice(0, 100);
    await eb.send(new CreateConfigurationTemplateCommand({
      ApplicationName: found.env.ApplicationName,
      TemplateName: template,
      EnvironmentId: found.env.EnvironmentId,
      Description: `Saved before terminating ${name}`,
    }));
    const out = await eb.send(new TerminateEnvironmentCommand({ EnvironmentId: found.env.EnvironmentId }));
    console.log(`${name}: configuration saved as ${template}; environment ${out.Status ?? "Terminating"}`);
  }
}

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

The client’s API surface and changelog live in the client-elastic-beanstalk package of the AWS SDK for JavaScript v3.

How do you run it?

Terminal

npm install @aws-sdk/client-elastic-beanstalk @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node

# Report on two Regions over 30 days
AWS_PROFILE=readonly npx tsx find-unused-elastic-beanstalk-environments.ts --regions us-east-1,eu-west-1 --days 30 --csv eb.csv

# Save configuration and terminate one idle environment
AWS_PROFILE=eb-admin npx tsx find-unused-elastic-beanstalk-environments.ts --apply --env shop-staging-old

Sample output

Output

┌─────────┬─────────────┬───────────────┬──────────────────────┬─────────────┬─────────┬─────────┬──────────────┬────────────────────────┬────────────────────┬──────────┬─────────────────────┐
│ (index) │ Region      │ Application   │ Environment          │ Tier        │ Status  │ Health  │ Updated      │ Instances              │ Traffic            │ PerMonth │ Verdict             │
├─────────┼─────────────┼───────────────┼──────────────────────┼─────────────┼─────────┼─────────┼──────────────┼────────────────────────┼────────────────────┼──────────┼─────────────────────┤
│ 0       │ 'us-east-1' │ 'shop'        │ 'shop-prod'          │ 'WebServer' │ 'Ready' │ 'Green' │ '2026-09-26' │ 't3.medium, t3.medium' │ '2814000 requests' │ '$77.16' │ 'in use'            │
│ 1       │ 'us-east-1' │ 'shop'        │ 'shop-staging-old'   │ 'WebServer' │ 'Ready' │ 'Grey'  │ '2026-04-21' │ 't3.small'             │ '0 requests'       │ '$31.61' │ 'IDLE: no requests' │
│ 2       │ 'us-east-1' │ 'shop'        │ 'shop-emails-worker' │ 'Worker'    │ 'Ready' │ 'Green' │ '2026-04-21' │ 't3.micro'             │ '0 messages'       │ '$7.59'  │ 'IDLE: no messages' │
│ 3       │ 'us-east-1' │ 'partner-api' │ 'api-demo'           │ 'WebServer' │ 'Ready' │ 'Green' │ '2026-06-30' │ 'm5.large'             │ '0 requests'       │ '$88.33' │ 'IDLE: no requests' │
└─────────┴─────────────┴───────────────┴──────────────────────┴─────────────┴─────────┴─────────┴──────────────┴────────────────────────┴────────────────────┴──────────┴─────────────────────┘
us-east-1 shop: 146 application versions, 144 not deployed (oldest 2024-10-08)
us-east-1 partner-api: 3 application versions, 2 not deployed (oldest 2025-12-02)
3 of 4 environments idle for 14 days: at least $127.53 a month (us-east-1 prices).

The run used mocked AWS responses, so names and numbers are illustrative. shop-staging-old, shop-emails-worker and api-demo had no traffic for 14 days and cost at least $127.53 a month together. Note the health column: the demo is Green and still idle. The shop application also holds 144 versions no environment runs.

What to check before you delete an unused Elastic Beanstalk environment

  • Coupled databases. If the environment created its own RDS database, terminating removes it unless the database deletion policy is set to retain it or take a snapshot. Check the database first; the script to find idle RDS instances shows whether it’s used at all. If an RDS Proxy sits in front of it, the script to find unused RDS proxies shows whether that is idle too.
  • DNS records. The Elastic Beanstalk docs warn that a terminated environment’s CNAME is freed for anyone to use. Delete your own DNS records that point at it, or you leave a dangling DNS entry.
  • Configuration. The script saves a configuration template first. AWS also lets you rebuild a terminated environment within six weeks (42 days), but the rebuild fails if its CNAME was taken or its application version was deleted.
  • Security group dependencies. Termination can fail when another environment’s security group references this one.

If an environment is used, but only lightly, right-size it instead: the script to detect underutilized EC2 instances by CPU shows which instances barely work. Instances that were stopped by hand outside Elastic Beanstalk turn up in the script to find long-stopped EC2 instances.

How do you clean up old application versions?

Every deploy creates an application version, and its source bundle stays in the Elastic Beanstalk S3 bucket by default. There’s a Region-wide quota on application versions across all applications, so a busy pipeline eventually can’t deploy. Set an application version lifecycle policy (UpdateApplicationResourceLifecycle) with a maximum count or age, and choose whether it deletes source bundles too. The docs say the policy never deletes versions an environment uses, or versions deployed to environments terminated less than ten weeks earlier. For the bucket itself, the script to find S3 buckets without lifecycle rules catches it if nothing expires old objects.

Troubleshooting

  • “check: no load balancer or queue metric”. A single-instance environment. Check its access logs, or ask its owner.
  • PerMonth shows “+ 1 unpriced”. The instance type isn’t in the script’s rate table; add it from the Price List for your Region.
  • Traffic is 0 for a busy environment. Check the Region, cloudwatch:GetMetricData, and whether traffic reaches it through another load balancer or CloudFront origin.
  • InsufficientPrivileges on terminate. The caller can’t delete an underlying resource. Troubleshooting AWS IAM access denied errors helps read the message.

Ask ChatWithCloud instead

Ask ChatWithCloud “Which Elastic Beanstalk environments had no requests on their load balancer in the last 14 days, and what instance types do they run?” It writes AWS SDK for JavaScript v2 code, runs it locally with your credentials and summarizes the answer; see how ChatWithCloud works from your terminal. It uses one profile and Region per session, can be wrong and runs changes without a confirmation step, so give ChatWithCloud a read-only AWS profile and terminate environments with the script. The AWS practical examples library has more cleanup reports like this one.

Frequently asked questions

Does Elastic Beanstalk charge for an environment nobody uses?

Elastic Beanstalk itself has no additional charge, but the environment’s EC2 instances, load balancer, EBS volumes and any attached database bill every hour until you terminate it.

Can I restore a terminated Elastic Beanstalk environment?

Yes, within six weeks (42 days) of termination, using Restore terminated environment in the console, eb restore or the RebuildEnvironment API. It fails if the CNAME was taken or the deployed application version was deleted.

Does terminating an environment delete its application versions?

No. Application versions belong to the application, not the environment, and their source bundles stay in S3 until a lifecycle policy or you delete them.

What does TerminateResources=false do?

It removes Elastic Beanstalk management but leaves the resources running, so it doesn’t save money. The default, true, terminates the Auto Scaling group, load balancer and other resources.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud