Schedule icon
AwsCLI icon
Script icon
SlackIncomingWebhook icon
If icon
Pause icon

AWS Idle Resource Reaper with Cost Estimate and Approval

Find idle AWS volumes, Elastic IPs, orphaned snapshots and stopped instances with Kestra, estimate the monthly waste, and clean up after approval.

Categories
CloudInfrastructure

Every AWS account collects leftovers: a volume detached during an incident and never deleted, an Elastic IP from a decommissioned server, snapshots from a migration last year, an instance someone stopped "for now". None of them show up as a problem, but every one of them bills you each month. This blueprint finds them, estimates what they cost, and reports to Slack. When you're ready, it deletes the safe-to-remove ones, but only after a person approves. It runs in dry-run mode by default, so nothing is ever deleted by surprise.

How it works

  1. scan (io.kestra.plugin.aws.cli.AwsCLI) does one read-only pass over the region. It reads the caller identity, unattached volumes, Elastic IPs, your own snapshots and AMIs, stopped instances, and every volume for cost lookups.

  2. analyze (io.kestra.plugin.scripts.python.Script) applies the rules:

    • Unattached EBS volumes older than volume_min_age_days, so a volume briefly detached for maintenance is never touched.
    • Elastic IPs not associated with anything. AWS bills every public IPv4 address, so an idle one costs about $3.65 a month.
    • Snapshots you own that are older than snapshot_min_age_days and don't back any of your AMIs. Deleting an AMI's snapshot would break the AMI, so those are always excluded.
    • Instances stopped for longer than stopped_min_days. These are reported with the cost of the volumes they still pay for, and they're never terminated automatically.
    • Anything with a tag whose key is keep_tag_key (default keep) is skipped and counted.

    It prices each resource from the prices variable, which holds us-east-1 list prices you can edit, and writes a CSV report.

  3. report posts the estimated monthly waste and the ten most expensive items to Slack on every run.

  4. cleanup (io.kestra.plugin.core.flow.If) only runs when dry_run is false and something can be removed:

    • request_approval asks in Slack, and approve_cleanup (io.kestra.plugin.core.flow.Pause) waits up to 48 hours. If nobody resumes the execution, it's cancelled and nothing is deleted.
    • delete_resources takes a tagged backup snapshot of each volume (created-by=kestra-idle-resource-reaper, source-volume=<id>), deletes the volume, releases the Elastic IPs, and deletes the old snapshots. Each resource is handled on its own. Failures are listed in cleanup_log.txt and fail the task only after the rest are processed.
    • confirm_cleanup reports what was removed and the monthly saving.
  5. alert_failure posts to Slack if the scan or the cleanup fails.

What you get

  • A weekly view of idle AWS spend with a dollar estimate, without logging in to the console.
  • A CSV report for each run, available as the report flow output, that you can share with finance or attach to a ticket.
  • Safe defaults: dry run, minimum ages, a keep tag, AMI-aware snapshot rules, a human approval, and a backup snapshot before any volume is deleted.
  • Backups that clean themselves up. Once a backup snapshot is older than snapshot_min_age_days, a later run picks it up like any other old snapshot.

Who it's for

  • Platform, DevOps and FinOps teams who want to cut AWS waste without writing and babysitting cleanup scripts.
  • Small teams and startups where nobody owns cost hygiene full time.
  • Anyone who has been surprised by an AWS bill line for "EBS" or "public IPv4".

Why orchestrate this with Kestra

A cleanup script is easy to write and risky to run unattended. Kestra splits it into a read-only scan, a reviewed report, a human approval and a logged cleanup. Each step has its own logs and outputs, and the approval can wait for days without holding a process. Secrets stay out of the flow, every run keeps its CSV report, and a failed deletion is visible and alerts Slack instead of disappearing in a cron log.

Prerequisites

  • An IAM user or role with sts:GetCallerIdentity, ec2:DescribeVolumes, ec2:DescribeAddresses, ec2:DescribeSnapshots, ec2:DescribeImages and ec2:DescribeInstances for scanning. Cleanup also needs ec2:CreateSnapshot, ec2:CreateTags, ec2:DeleteVolume, ec2:ReleaseAddress and ec2:DeleteSnapshot.
  • A Slack incoming webhook.
  • A Kestra worker with Docker available. The AWS CLI and the Python step run in containers.

Secrets

  • AWS_ACCESS_KEY_ID: access key of the IAM identity above.
  • AWS_SECRET_ACCESS_KEY: its secret key.
  • SLACK_WEBHOOK_URL: Slack incoming webhook for reports, approvals and alerts.

Inputs

  • region (STRING, default us-east-1): region to scan.
  • dry_run (BOOL, default true): report only. Set to false to allow deletion after approval.
  • volume_min_age_days (INT, default 7): ignore unattached volumes younger than this.
  • snapshot_min_age_days (INT, default 90): only snapshots older than this are candidates.
  • stopped_min_days (INT, default 30): report instances stopped for longer than this.
  • keep_tag_key (STRING, default keep): resources with a tag of this key are always skipped.
  • snapshot_before_delete (BOOL, default true): back up each volume before deleting it.
  • endpoint_url (STRING, optional): an emulator endpoint, for trying the flow without AWS. Leave it empty for real AWS.

Outputs

  • {{ outputs.report }} (flow output) / {{ outputs.analyze.outputFiles['idle_resources.csv'] }}: CSV with type, ID, name, detail, estimated monthly cost and proposed action for every idle resource.
  • {{ outputs.monthly_waste_usd }} (flow output) / {{ outputs.analyze.vars.monthly_waste_usd }}: estimated monthly waste in USD.
  • {{ outputs.analyze.vars.deletable_monthly_usd }}: the part that cleanup would remove.
  • {{ outputs.analyze.vars.volume_ids }}, {{ outputs.analyze.vars.address_ids }}, {{ outputs.analyze.vars.snapshot_ids }}, {{ outputs.analyze.vars.instance_ids }}: the candidates by type.
  • {{ outputs.delete_resources.outputFiles['cleanup_log.txt'] }}: what was backed up, deleted or released, and anything that failed.

Quick start

  1. Create the secrets above and save the flow.
  2. Run it with the defaults. It's a dry run, so you'll get the Slack report and the CSV without any change to your account.
  3. Tag anything you want to keep with keep, then run again with dry_run: false and resume the execution when Slack asks for approval.
  4. Enable the weekly_scan trigger for a weekly report. Scheduled runs use the input defaults, so they stay in dry-run mode unless you change the default.

Try it without an AWS account

The flow works against Moto, an open-source AWS emulator that needs no account:

  1. Run docker run -d -p 5050:5000 motoserver/moto. Port 5050 avoids a clash on macOS, where AirPlay Receiver already listens on port 5000.
  2. Set the AWS secrets to test and test.
  3. Create a few resources with the AWS CLI, for example aws --endpoint-url http://localhost:5050 ec2 create-volume --availability-zone us-east-1a --size 20 and aws --endpoint-url http://localhost:5050 ec2 allocate-address.
  4. Run the flow with endpoint_url: http://host.docker.internal:5050, and set the three age inputs to 0, since emulated resources are brand new.

LocalStack works the same way through endpoint_url, but it now requires a LocalStack account and auth token, even on its free plan.

How to extend

  • Scan several regions with a io.kestra.plugin.core.flow.Loop over a list of regions, or several accounts with stsRoleArn on the AWS tasks.
  • Add more resource types the same way, such as idle load balancers, unattached network interfaces or old AMIs.
  • Send the CSV to S3 with io.kestra.plugin.aws.s3.Upload to keep a history of savings, or post it to Jira for owners to review.
  • Replace the list prices in prices with live figures from the AWS Pricing API or your Cost and Usage Report.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.