Finds the AWS resources that are dead but still billing.
A command-line tool that looks through your AWS account, lists the resources nothing is using, and shows roughly what each one costs per month. It only reads. It never creates, changes or deletes anything.
$ zombie-scanner --all-regions
Account 123456789012 · us-east-1, eu-west-1 · scanned 2026-09-04 15:44 UTC
RESOURCE TYPE REGION CONF ~$/MO REASON
nat-1fd0486ae1b9f4058 nat-gateway eu-west-1 MEDIUM $34.82 104799 bytes out over 14 days (checked 2026-09-04)
i-0de1d1d34a6dd890c ec2-instance us-east-1 MEDIUM $58.00 Stopped 171 days ago (t3.large); 2 attached volume(s) still billing
vol-01aa332c9f5cd7872 ebs-volume us-east-1 HIGH $8.00 Unattached; created 412 days ago (100 GiB gp3)
snap-09ea129477b096c4e ebs-snapshot us-east-1 LOW $5.00 Created 412 days ago (100 GiB), not referenced by any AMI
eipalloc-02…968f11221f elastic-ip us-east-1 HIGH $3.65 Public IP 100.29.56.194 is allocated but associated with nothing
5 zombies · estimated zombie spend ~$109.47/month · figures are estimatesEvery AWS resource bills one of two ways:
| Billing shape | Meaning | Cost when idle |
|---|---|---|
| Usage-billed | Charged for work done — Lambda calls, S3 GETs | ~$0 |
| Existence-billed | Charged for existing — EBS, NAT, Elastic IPs | Full price |
A zombie is an existence-billed resource that nothing is using.
A volume detached during a migration eighteen months ago. A NAT gateway in a VPC nobody deploys to any more. An Elastic IP left behind by a terminated instance.
None of them show up in a cost anomaly report, because nothing anomalous is happening. They just bill, every hour, indefinitely.
Go
go install github.com/Sachinxmpl/zombie-scanner@latestLinux packages (use arm64 instead of amd64 if needed)
VERSION=$(curl -s https://api.github.com/repos/Sachinxmpl/zombie-scanner/releases/latest \
| grep -oP '"tag_name": "v\K[^"]+')
BASE=https://github.com/Sachinxmpl/zombie-scanner/releases/download/v${VERSION}
# Debian / Ubuntu
curl -sLO ${BASE}/zombie-scanner_${VERSION}_linux_amd64.deb
sudo dpkg -i zombie-scanner_${VERSION}_linux_amd64.deb
# RHEL / Fedora
sudo rpm -i ${BASE}/zombie-scanner_${VERSION}_linux_amd64.rpm
# Alpine
curl -sLO ${BASE}/zombie-scanner_${VERSION}_linux_amd64.apk
sudo apk add --allow-untrusted zombie-scanner_${VERSION}_linux_amd64.apkReady-made binaries for Linux, macOS and Windows are on the releases page.
From source Code (Go 1.25.14 or later)
git clone https://github.com/Sachinxmpl/zombie-scanner
cd zombie-scanner
make buildEach release comes with a checksum file and a GitHub attestation that proves the file was built by this repository's release workflow:
sha256sum -c checksums.txt --ignore-missing
gh attestation verify zombie-scanner_*_linux_amd64.tar.gz --repo Sachinxmpl/zombie-scannerCredentials come from the standard AWS chain — environment variables,
~/.aws/config, SSO, instance roles. If aws sts get-caller-identity works,
this works.
zombie-scanner # your default region
zombie-scanner --region eu-west-1
zombie-scanner --all-regions
zombie-scanner --profile prod --all-regionsAvailable Commands
zombie-scanner [command]
scan Scan for zombie resources (default action)
help Help about any command
detectors List the available detectors
iam-policy Print the minimal IAM policy this tool needs
render Re-render a saved JSON report in another format
version Print the versionFilters
zombie-scanner --min-cost 10 # only things costing over $10/month
zombie-scanner --confidence high # only the surest findings
zombie-scanner --only ebs-unattached,nat-idle # only these checks
zombie-scanner --skip snapshot-aged # everything except this checkChange the limits
zombie-scanner --snapshot-age-days 180 --stopped-days 60 --idle-window-days 30See how each cost was derived
zombie-scanner -vEvery flag can also be set with an environment variable:
ZOMBIE_SCANNER_MIN_COST=25 zombie-scanner --all-regions
Precedence is flag > environment > default.
Some "zombies" are on purpose, like a standby database. There are three ways to stop seeing them.
1. Tag the resource with zombie-scanner:keep. Any value works; write why.
aws ec2 create-tags --resources vol-0abc --tags Key=zombie-scanner:keep,Value="DR standby"Use --keep-tag to pick a different tag name, or --no-keep-tag to show
everything.
2. Add an ignore rule in .zombie-scanner.yaml. It reads from the current
folder first, then your home folder.
min-cost: 1
idle-window-days: 30
ignore:
- id: vol-0abc
reason: kept for the quarterly restore test
- detector: snapshot-aged
region: eu-west-1
reason: compliance keeps these for 7 yearsEach rule needs a reason and at least one of id, detector or region. If a
rule matched nothing, the tool tells you, so old rules don't pile up. The file
can also hold your usual settings: region, profile, output, min-cost,
confidence, keep-tag and the three limits. A flag or environment variable
beats the file. Use --no-config to ignore the file.
3. Point it at your Terraform state. Anything Terraform manages is there on purpose, so it is hidden:
zombie-scanner --tf-state ./terraform.tfstate
zombie-scanner --tf-state ./states/ # every .tfstate file in the folderWhatever is hidden is counted at the bottom of the report, e.g. "3 hidden by --min-cost", so nothing disappears silently.
zombie-scanner --accounts 111111111111,222222222222 --all-regionsThis scans your own account, plus each listed account by assuming the role
ZombieScannerReadOnly in it (change it with --role-name). That role needs the
policy from zombie-scanner iam-policy and must trust your account. With more
than one account, the report adds an Account column.
| Detector | Confidence | What it looks for | Cost basis |
|---|---|---|---|
ebs-unattached |
HIGH | Volumes in state available |
size × per-GiB rate |
eip-unassociated |
HIGH | Elastic IPs attached to nothing | flat monthly |
efs-unused |
HIGH | File systems with no mount targets | size × per-tier rate |
kms-key-disabled |
HIGH | Disabled customer-managed encryption keys | flat $1/key |
rds-stopped |
HIGH | Stopped databases still paying for storage | allocated GiB × rate |
instance-stopped |
MEDIUM | Stopped instances still paying for their disks | sum of attached volumes |
rds-idle |
MEDIUM | Running databases nothing connected to | class $/hr × 730 + storage |
nat-idle |
MEDIUM | NAT gateways below an outbound-bytes floor | hourly × 730 |
elb-idle |
MEDIUM | ALBs below a request-count floor | hourly × 730 |
snapshot-aged |
LOW | Old snapshots that no AMI references | size × snapshot rate |
rds-snapshot-aged |
LOW | Old manual database snapshots | size × backup rate |
ecr-untagged |
LOW–MEDIUM | Old untagged container images | size × storage rate |
A few are worth calling out.
rds-stopped is the one that costs people the most. Stopping an RDS
instance stops the compute charge only — allocated storage bills at the full
per-GiB rate for as long as the instance exists. And you cannot leave one
stopped: AWS restarts a stopped database automatically after 7 days. So the
real pattern is stop, forget, get restarted, notice a week later, stop again —
paying storage the whole time and compute for the gaps. The finding tells you
the exact date AWS will restart it.
instance-stopped surprises people. Stopping an instance stops the compute
charge — the attached EBS volumes keep billing at full price, indefinitely. A
stopped t3.large with a 500 GiB root volume is roughly $50/month that looks
free in the console.
efs-unused finds network file systems nothing can reach. A mount target
is the only way into EFS, so none means no server can read or write it — yet
every byte still bills, at the priciest storage rate AWS has. Each storage tier
is priced at its own rate, from the exact sizes AWS reports.
rds-idle is usually the largest single number in the report, because a
running database bills for its instance class as well as its storage. When the
class is one it does not have a rate for, or the engine is Oracle or SQL Server
where the licence dominates, it reports the storage cost and says in
cost_basis that compute was left out — it never guesses. RDS classes span a
500x range; a wrong guess would be worse than a visible gap.
snapshot-aged cross-references every registered AMI before flagging
anything, because deleting a snapshot an AMI depends on breaks the AMI. If that
cross-reference cannot be built — a missing permission, say — the check reports
nothing rather than guessing.
rds-snapshot-aged looks only at manual snapshots: automated ones expire
on their own. A manual snapshot stays until someone deletes it, even after its
database is gone. It is priced on the snapshot's real size; when AWS does not
report that, it uses the database's size and marks the price as an upper bound.
ecr-untagged finds the images a build pipeline leaves behind when it
pushes a new image under an old tag. It is LOW by default and MEDIUM only when
ECR has recorded a pull and that pull is old. A missing pull time is not taken
as "never pulled", because ECR only records pulls since it started tracking
them. The price is an upper bound: images share layers, which are stored once.
kms-key-disabled and ecr-untagged need one extra call per key or
repository. If a key or repository policy blocks that call, the rest are still
checked and the report says how many could not be read.
Every finding says how sure the tool is:
| Level | Meaning | Example |
|---|---|---|
| HIGH | AWS says outright it is unused | a disk attached to nothing |
| MEDIUM | Usage graphs show no real use for days | a NAT with no traffic in 14 days |
| LOW | Probably unused; a person should check | a snapshot older than 90 days |
Two rules are never broken:
- Something newer than the lookback window is never judged. Too little history to tell.
- No usage data does not mean unused. If CloudWatch returns nothing, the tool says nothing.
zombie-scanner --all-regions --fail-if-above 100| Exit code | Meaning |
|---|---|
0 |
Scan finished (zombies may exist) |
1 |
Error: no credentials, bad flags |
2 |
Scan finished, spend was above --fail-if-above |
If a permission is missing, that check is skipped with a note saying what to
allow, and the rest still run. Add --strict to fail instead.
- uses: Sachinxmpl/zombie-scanner@v0.2.0
with:
args: --all-regions --min-cost 1
fail-if-above: "50" # optionalThe Action downloads the matching release, checks it is genuine, runs the scan
and puts the report on the job's summary page. Later steps can read
monthly-usd, count and report (the path of the JSON file). A skipped check
shows as a warning; add --strict to args to fail the job instead.
A full weekly workflow that logs in to AWS without stored keys: docs/examples/scheduled-scan.yml.
zombie-scanner --output json > report.json
zombie-scanner render report.json --output markdownrender does not touch AWS. It prints a saved report as a table, JSON or
Markdown.
zombie-scanner --json | jq '.summary.total_monthly_usd'
zombie-scanner --json | jq -r '.findings[] | select(.confidence == "HIGH") | .resource_id'{
"schema_version": "1",
"tool": { "name": "zombie-scanner", "version": "0.2.0", "commit": "b1b2aa5" },
"account_id": "123456789012",
"accounts": ["123456789012"],
"regions": ["us-east-1"],
"findings": [
{
"resource_id": "vol-01aa332c9f5cd7872",
"resource_type": "ebs-volume",
"region": "us-east-1",
"account_id": "123456789012",
"detector": "ebs-unattached",
"confidence": "HIGH",
"reason": "Unattached; created 412 days ago (100 GiB gp3)",
"monthly_cost_usd": 8,
"cost_basis": "100 GiB x $0.080/GiB-mo x 1.00 (us-east-1)"
}
],
"errors": [],
"summary": {
"total_monthly_usd": 8,
"zombie_count": 1,
"by_confidence": { "HIGH": 1 },
"by_detector_monthly_usd": { "ebs-unattached": 8 }
}
}findings and errors are always lists, never null. Check errors to tell
"nothing found" apart from "couldn't look". Field names only change if
schema_version changes.
zombie-scanner iam-policy # the policy for every check
zombie-scanner iam-policy --only ebs-unattached # just for one checkThe policy is built from the code, so it always matches what the tool calls.
A copy is in docs/iam-policy.json. Every action in it is
a Describe, Get or List. AWS's ReadOnlyAccess policy also works, but
allows far more.
There is no --delete flag and there won't be. A wrong report is annoying; a
wrong delete is an outage. You decide what to delete.
CI checks this on every change:
- every AWS call the tool can make must be a
Describe,GetorList - only one package is allowed to talk to AWS, so nothing can go around that rule
- known write calls are on a blocklist
- the checking and pricing code can't use the AWS SDK at all
See scripts/.
Prices come from AWS's public list prices, stored in
price/rates.json. Your real bill may be lower because of
discounts or savings plans. Use -v to see the sum behind each number:
$ zombie-scanner -v
vol-01aa332c9f5cd7872 ebs-volume us-east-1 HIGH $8.00 Unattached; created 412 days ago (100 GiB gp3)
100 GiB x $0.080/GiB-mo x 1.00 (us-east-1)| Tool | What it does | How this differs |
|---|---|---|
| awsweeper | Deletes resources matching a YAML filter | It executes an intent you already have; this supplies the intent |
| aws-nuke | Deletes everything in an account | Teardown, not discovery |
| cloud-custodian | Policy engine — you write the rules | This ships the rules |
| komiser | Cost dashboard with a server and a UI | This is one binary and one command |
| infracost | Cost of Terraform changes before apply | This looks at what is already running |
| Trusted Advisor | AWS's own | Paywalled tiers, console-first, no CLI, no exit code |
Bug reports and ideas for new checks are welcome. A false positive (something flagged that is actually in use) is the most useful bug you can report.
make build # compile
make test # run the tests
make lint # run the linter
make verify # the read-only checks
make check # everything CI runsIt needs only read access, and talks to nothing but AWS. No tracking. To report a problem, see SECURITY.md.
Released under the LICENSE.