To delete old AMIs and snapshots safely, list the AMIs you own, drop any still referenced by an instance or launch template, deregister the rest with DeregisterImage, then delete EBS snapshots older than your cutoff with DeleteSnapshot, skipping every snapshot that still backs a registered AMI. Order matters: AWS won’t delete a snapshot while an AMI uses it.
Image pipelines, nightly backups and “just in case” AMIs pile up fast, and the snapshots behind them are billed by the gigabyte every month. This example gives you a TypeScript script for the AWS SDK for JavaScript v3 that finds AMIs and snapshots older than 30 days (or any number you choose), explains why each one stays or goes, and changes nothing until you pass --apply.
It belongs with the other AWS SDK v3 cleanup and cost examples. If you’re cleaning up because storage costs grew, the example that breaks down last month’s AWS bill by service shows whether EC2 snapshots are really the problem.
What does this script do?
- Collect AMIs in useIt reads every instance that isn’t terminated with
paginateDescribeInstances, and the$Latestand$Defaultversion of every launch template withpaginateDescribeLaunchTemplateVersions. AnyImageIdfound there is off limits. - Choose AMIs to deregisterIt lists your own AMIs (
Owners: ["self"]) and marks those whoseCreationDateis older than the cutoff, unless they’re in use, taggedkeep=true, or have deregistration protection turned on. - Protect snapshots that still matterEvery snapshot referenced in the
BlockDeviceMappingsof an AMI that stays registered is protected. Snapshots with tags startingaws:dlm:oraws:backup:are skipped too, so Data Lifecycle Manager and AWS Backup can expire what they created. - Choose snapshots to deleteYour snapshots (
OwnerIds: ["self"]) older than the cutoff byStartTimeare marked, minus the protected ones and anything taggedkeep=true. - Report, or apply in the safe orderBy default it prints the plan and stops. With
--applyit deregisters AMIs first, then deletes snapshots. If an AMI fails to deregister, its snapshots are dropped from the delete list.
Prerequisites
- Node.js 18 or later, npm, and
tsx. - The
@aws-sdk/client-ec2package. - An AWS profile for the target region. The dry run needs describe permissions only.
Which IAM permissions does it need?
The four describe actions don’t support resource-level permissions. ec2:DeregisterImage and ec2:DeleteSnapshot can be scoped to image and snapshot ARNs, which have no account ID in them. Leave the second statement off the policy you use for dry runs.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FindOldImagesAndSnapshots",
"Effect": "Allow",
"Action": [
"ec2:DescribeImages",
"ec2:DescribeSnapshots",
"ec2:DescribeInstances",
"ec2:DescribeLaunchTemplateVersions"
],
"Resource": "*"
},
{
"Sid": "RemoveOldImagesAndSnapshots",
"Effect": "Allow",
"Action": [
"ec2:DeregisterImage",
"ec2:DeleteSnapshot"
],
"Resource": [
"arn:aws:ec2:*::image/*",
"arn:aws:ec2:*::snapshot/*"
]
}
]
}
To tighten it further, add a condition on a tag your image pipeline sets. The free IAM policy generator for TypeScript drafts a policy from a modified script, and the guide to review IAM policies for least privilege shows what to check before attaching it.
The full script to delete old AMIs and snapshots
// cleanup-old-amis-snapshots.ts
// Finds AMIs you own that are older than N days (default 30) and not used by any
// instance or launch template, plus EBS snapshots older than N days that no
// remaining AMI needs. Report-only by default; pass --apply to deregister and delete.
import {
EC2Client,
paginateDescribeImages,
paginateDescribeInstances,
paginateDescribeLaunchTemplateVersions,
paginateDescribeSnapshots,
DeregisterImageCommand,
DeleteSnapshotCommand,
type Image,
type Tag,
} from "@aws-sdk/client-ec2";
const args = process.argv.slice(2);
const APPLY = args.includes("--apply");
const daysArg = args.indexOf("--days");
const DAYS = daysArg !== -1 ? Number(args[daysArg + 1]) : 30;
if (!Number.isFinite(DAYS) || DAYS < 1) throw new Error("--days must be a positive number");
const region = process.env.AWS_REGION ?? "us-east-1";
const ec2 = new EC2Client({ region });
const cutoff = new Date(Date.now() - DAYS * 24 * 60 * 60 * 1000);
const isKept = (tags: Tag[] | undefined) =>
(tags ?? []).some((t) => t.Key?.toLowerCase() === "keep" && t.Value?.toLowerCase() === "true");
const isManaged = (tags: Tag[] | undefined) =>
(tags ?? []).some((t) => t.Key?.startsWith("aws:dlm:") || t.Key?.startsWith("aws:backup:"));
const snapshotsOf = (image: Image) =>
(image.BlockDeviceMappings ?? []).map((m) => m.Ebs?.SnapshotId).filter((id): id is string => !!id);
// AMI IDs referenced by instances in any state except terminated, and by launch templates.
async function amisInUse(): Promise<Set<string>> {
const used = new Set<string>();
const instanceFilter = [
{ Name: "instance-state-name", Values: ["pending", "running", "stopping", "stopped", "shutting-down"] },
];
for await (const page of paginateDescribeInstances({ client: ec2 }, { Filters: instanceFilter })) {
for (const r of page.Reservations ?? []) {
for (const i of r.Instances ?? []) if (i.ImageId) used.add(i.ImageId);
}
}
// Without a template ID, $Latest and $Default return those versions of every template.
for await (const page of paginateDescribeLaunchTemplateVersions(
{ client: ec2 },
{ Versions: ["$Latest", "$Default"] },
)) {
for (const v of page.LaunchTemplateVersions ?? []) {
const imageId = v.LaunchTemplateData?.ImageId;
if (imageId) used.add(imageId);
}
}
return used;
}
async function main(): Promise<void> {
console.log(`${APPLY ? "APPLY" : "DRY RUN"} in ${region}: older than ${DAYS} days (${cutoff.toISOString()})`);
const inUse = await amisInUse();
const images: Image[] = [];
// IncludeDisabled so snapshots behind disabled AMIs are protected (or cleaned up with them).
for await (const page of paginateDescribeImages({ client: ec2 }, { Owners: ["self"], IncludeDisabled: true })) {
images.push(...(page.Images ?? []));
}
// 1. Decide which AMIs go.
const amisToRemove: Image[] = [];
for (const image of images) {
const created = image.CreationDate ? new Date(image.CreationDate) : undefined;
if (!image.ImageId || !created || created >= cutoff) continue;
let reason = "";
if (inUse.has(image.ImageId)) reason = "used by an instance or launch template";
else if (isKept(image.Tags)) reason = "tagged keep=true";
else if (image.DeregistrationProtection?.startsWith("enabled")) reason = "deregistration protection";
console.log(`AMI ${image.ImageId} ${image.Name ?? ""} (${image.CreationDate}): ${reason ? `skip, ${reason}` : "remove"}`);
if (!reason) amisToRemove.push(image);
}
// 2. Snapshots still needed by any AMI that stays registered are off limits.
const removing = new Set(amisToRemove.map((i) => i.ImageId));
const protectedSnapshots = new Set(
images.filter((i) => !removing.has(i.ImageId)).flatMap(snapshotsOf),
);
const snapshotsToDelete = new Set<string>();
for await (const page of paginateDescribeSnapshots({ client: ec2 }, { OwnerIds: ["self"] })) {
for (const s of page.Snapshots ?? []) {
if (!s.SnapshotId || !s.StartTime || s.StartTime >= cutoff) continue;
let reason = "";
if (protectedSnapshots.has(s.SnapshotId)) reason = "backs a registered AMI";
else if (isKept(s.Tags)) reason = "tagged keep=true";
else if (isManaged(s.Tags)) reason = "managed by DLM or AWS Backup";
console.log(`Snapshot ${s.SnapshotId} ${s.VolumeSize ?? "?"} GiB (${s.StartTime.toISOString()}): ${reason ? `skip, ${reason}` : "delete"}`);
if (!reason) snapshotsToDelete.add(s.SnapshotId);
}
}
console.log(`\nPlan: deregister ${amisToRemove.length} AMI(s), delete ${snapshotsToDelete.size} snapshot(s).`);
if (!APPLY) {
console.log("Dry run: nothing was changed. Re-run with --apply to act.");
return;
}
// 3. Deregister first: a snapshot that backs a registered AMI can't be deleted.
for (const image of amisToRemove) {
try {
await ec2.send(new DeregisterImageCommand({ ImageId: image.ImageId }));
console.log(`Deregistered ${image.ImageId}`);
} catch (err) {
console.error(`Could not deregister ${image.ImageId}: ${(err as Error).message}`);
for (const id of snapshotsOf(image)) snapshotsToDelete.delete(id); // still in use
}
}
for (const id of snapshotsToDelete) {
try {
await ec2.send(new DeleteSnapshotCommand({ SnapshotId: id }));
console.log(`Deleted ${id}`);
} catch (err) {
console.error(`Could not delete ${id}: ${(err as Error).message}`);
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
The script doesn’t use the DeleteAssociatedSnapshots option of DeregisterImage. Deleting snapshots in a separate pass means every snapshot goes through the same checks, including the one for snapshots shared by more than one AMI. The @aws-sdk/client-ec2 package in the AWS SDK for JavaScript v3 repository lists every command and paginator used here.
How do you run it?
npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Dry run: report AMIs and snapshots older than 30 days (the default)
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx cleanup-old-amis-snapshots.ts
# Use a 90-day cutoff instead
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx cleanup-old-amis-snapshots.ts --days 90
# Act on the plan after reviewing it
AWS_PROFILE=admin AWS_REGION=us-east-1 npx tsx cleanup-old-amis-snapshots.ts --days 90 --apply
Deletion is permanent unless a Recycle Bin retention rule covers EBS snapshots or AMIs in that region. Run the dry run, read every “remove” and “delete” line, and tag anything you want to keep with keep=true before you add --apply.
Sample output
DRY RUN in us-east-1: older than 30 days (2026-08-28T09:14:02.511Z)
AMI ami-0a12b34c56d78e901 web-2026-06-02 (2026-06-02T03:10:44.000Z): remove
AMI ami-0b98c76d54e32f109 web-2026-07-01 (2026-07-01T03:11:02.000Z): skip, used by an instance or launch template
AMI ami-0c55d44e33f22a110 golden-base (2026-03-15T12:00:31.000Z): skip, tagged keep=true
Snapshot snap-01a2b3c4d5e6f7a8b 30 GiB (2026-06-02T03:10:47.000Z): delete
Snapshot snap-02b3c4d5e6f7a8b9c 30 GiB (2026-07-01T03:11:05.000Z): skip, backs a registered AMI
Snapshot snap-03c4d5e6f7a8b9c0d 100 GiB (2026-05-20T01:00:12.000Z): delete
Snapshot snap-04d5e6f7a8b9c0d1e 50 GiB (2026-08-01T05:00:00.000Z): skip, managed by DLM or AWS Backup
Plan: deregister 1 AMI(s), delete 2 snapshot(s).
Dry run: nothing was changed. Re-run with --apply to act.
IDs, names and dates are illustrative. The snap-01… snapshot is deletable only because the AMI it backs is being deregistered in the same run.
Troubleshooting: why can’t a snapshot be deleted?
InvalidSnapshot.InUse. A registered AMI still uses the snapshot, for example an AMI someone created from it after your dry run. Deregister that AMI first, or leave the snapshot alone.UnauthorizedOperationonDeregisterImageorDeleteSnapshot. The profile has only the describe statement, or an SCP blocks deletes. The guide to fix AWS IAM access denied errors step by step walks through the policy layers.- An AMI you expected isn’t listed. The script only lists AMIs your account owns; images shared with you by other accounts aren’t yours to clean up. Disabled AMIs are included because
DescribeImagesis called withIncludeDisabled: true, so their snapshots stay protected unless the AMI itself is removed. - Another account still launches from your AMI. The script can’t see instances or launch templates in accounts you shared the AMI with. Check sharing before you deregister a shared image.
- Auto Scaling groups still use a launch configuration. The script checks instances and launch templates, not legacy launch configurations. List those separately if you still use them.
What happens to instances when you deregister an AMI?
Nothing. Running and stopped instances launched from a deregistered AMI keep working, because their volumes are independent copies. You just can’t launch new instances from that AMI. The script still skips AMIs referenced by existing instances, because those are often the images you’d need to rebuild them. For the volumes those instances leave behind, the example to find unattached EBS volumes and tag them covers the next layer of storage cleanup, and releasing unassociated Elastic IPs handles the networking leftovers.
Ask ChatWithCloud instead
You can also start with a question: run ChatWithCloud and ask “Which of my AMIs are older than 30 days and not used by any instance, and how much snapshot storage do they hold?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and answers from the result. Because changes run without a confirmation step, don’t ask it to delete things; use a read-only profile to explore and run the script above to act. The guide to connect ChatWithCloud to a read-only AWS profile shows the setup, and ChatWithCloud’s security and data handling explains what’s sent for processing.
Frequently asked questions
Does deregistering an AMI delete its snapshots?
Not by default. The snapshots stay and keep costing money until you delete them. DeregisterImage has a DeleteAssociatedSnapshots option, but this script deletes snapshots in a separate, checked pass instead.
Why can’t I delete a snapshot that belongs to an AMI?
EC2 blocks deleting a snapshot while a registered AMI references it. Deregister the AMI first, then delete the snapshot.
Can I recover deleted AMIs or snapshots?
Only if a Recycle Bin retention rule for that resource type was in place before you deleted them. Resources in the Recycle Bin can be restored until the retention period ends.
How do I keep a specific AMI or snapshot out of the cleanup?
Tag it keep=true. The script skips any AMI or snapshot with that tag, whatever its age.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
