An ECR lifecycle policy deletes old images for you: rules such as “expire untagged images older than 14 days” and “keep only the newest 30 images” run on each repository, and Amazon ECR expires matching images within 24 hours. The script below finds repositories with no policy, estimates the storage each would free, and writes the policy only when you pass --apply.
Every CI build that pushes to Amazon ECR adds an image, and nothing removes them unless you ask. A busy service repository can hold hundreds of images, most of them untagged leftovers from a tag that moved to a newer build. This example is for teams who want an ECR lifecycle policy to delete old images across every repository in a region, without hand-editing each one in the console. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that reports first and writes second.
It’s one of our AWS cost cleanup examples with complete scripts, and it works like the script to set CloudWatch log retention for all log groups: find the resources with no expiry rule, then add one.
What do old ECR images cost?
Private repository storage in US East (N. Virginia) costs $0.10 per GB-month as of September 2026, according to the Amazon ECR pricing page. New customers get 500 MB per month of private repository storage free for one year. Data transfer between ECR and other services in the same region is free.
| Repository | Stored | Arithmetic | Per month |
|---|---|---|---|
| Small service, 50 images of 200 MB | 10 GB | 10 × $0.10 | $1.00 |
| Busy service, 400 images of 250 MB | 100 GB | 100 × $0.10 | $10.00 |
| ML images, 150 images of 4 GB | 600 GB | 600 × $0.10 | $60.00 |
The per-repository numbers are small, which is exactly why nobody fixes them. Across dozens of repositories and a few years of builds, ECR storage becomes a line worth reading in the report to get last month’s AWS cost broken down by service. imageSizeInBytes reports the compressed size ECR stores, which is smaller than what docker images shows locally.
How do ECR lifecycle policy rules work?
A lifecycle policy is a JSON document with one or more rules. The Amazon ECR lifecycle policies guide sets out how they’re evaluated. The parts that matter for deleting old images:
rulePriorityorders the rules, lowest first. A rule withtagStatus: "any"must have the highest value and runs last.tagStatusisuntagged,tagged(which needs atagPatternListortagPrefixList) orany.countTypeissinceImagePushed(withcountUnit: "days") for age, orimageCountMoreThanto keep only the newest N images.- One rule per image. All rules are evaluated together, then applied by priority. An image matched by a higher-priority rule can’t be expired by a lower one, but lower rules still count it.
- Manifest lists protect their images. An image referenced by a multi-architecture manifest list can’t be expired until the manifest list is deleted.
The script’s policy uses two rules. Rule 1 expires untagged images pushed more than 14 days ago. Rule 2 keeps the newest 30 images and expires older tagged ones. Recent untagged images still count toward those 30, so leave some headroom when you choose --keep. You can also expire by pull date, but only indirectly: sinceImagePulled works only with the transition action to archive storage, and archived images must stay archived for at least 90 days before a rule can delete them.
What does the script do?
- Lists repositories
paginateDescribeRepositoriesin the region fromAWS_REGIONor your profile. - Reads every image
paginateDescribeImagesfor counts, untagged images, push dates and sizes. - Checks for a policy
GetLifecyclePolicy; aLifecyclePolicyNotFoundExceptionmeans none is set. - Estimates the effectApplies both rules locally to repositories without a policy and totals the images and GB that would expire.
- Writes only with
--applyPutLifecyclePolicyon repositories that have no policy. Existing policies are never touched.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-ecrpackage. - A profile with a default region, or
AWS_REGIONset. ECR repositories are regional; run once per region.
Which IAM permissions does it need?
The first statement covers the dry run. Add the second only for --apply. Replace 123456789012 and us-east-1, or narrow repository/* to a name prefix.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadRepositoriesAndImages",
"Effect": "Allow",
"Action": [
"ecr:DescribeRepositories",
"ecr:DescribeImages",
"ecr:GetLifecyclePolicy"
],
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/*"
},
{
"Sid": "SetLifecyclePolicies",
"Effect": "Allow",
"Action": "ecr:PutLifecyclePolicy",
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/*"
}
]
}
To check a policy against your own changes to the script, paste it into the AI IAM policy generator for TypeScript code, then compare the result with the steps in the guide to find the IAM actions your AWS SDK for JavaScript code needs.
The full script: set an ECR lifecycle policy to delete old images
// set-ecr-lifecycle-policies.ts
// Finds ECR repositories in one region without a lifecycle policy, estimates the storage an
// "untagged older than N days + keep the newest K images" policy would free, and applies that
// policy only when you pass --apply. Repositories that already have a policy are never changed.
// Usage: npx tsx set-ecr-lifecycle-policies.ts [--untagged-days 14] [--keep 30] [--apply]
import {
ECRClient,
paginateDescribeRepositories,
paginateDescribeImages,
GetLifecyclePolicyCommand,
PutLifecyclePolicyCommand,
LifecyclePolicyNotFoundException,
type ImageDetail,
} from "@aws-sdk/client-ecr";
const ECR_PER_GB_MONTH = 0.1; // USD, private repository storage, us-east-1, as of September 2026
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i === -1 ? undefined : process.argv[i + 1];
}
const ecr = new ECRClient({}); // region from AWS_REGION or your profile
function policy(untaggedDays: number, keep: number): string {
return JSON.stringify({
rules: [
{
rulePriority: 1,
description: `Expire untagged images older than ${untaggedDays} days`,
selection: { tagStatus: "untagged", countType: "sinceImagePushed", countUnit: "days", countNumber: untaggedDays },
action: { type: "expire" },
},
{
rulePriority: 2, // a tagStatus "any" rule must have the highest rulePriority
description: `Keep only the newest ${keep} images`,
selection: { tagStatus: "any", countType: "imageCountMoreThan", countNumber: keep },
action: { type: "expire" },
},
],
});
}
async function hasPolicy(repositoryName: string): Promise<boolean> {
try {
await ecr.send(new GetLifecyclePolicyCommand({ repositoryName }));
return true;
} catch (err) {
if (err instanceof LifecyclePolicyNotFoundException) return false;
throw err;
}
}
// Rough local estimate of what the policy would expire. Use the lifecycle policy preview for the exact list.
function estimate(images: ImageDetail[], untaggedDays: number, keep: number): ImageDetail[] {
const cutoff = Date.now() - untaggedDays * 86_400_000;
const newestFirst = [...images].sort((a, b) => (b.imagePushedAt?.getTime() ?? 0) - (a.imagePushedAt?.getTime() ?? 0));
const isUntagged = (img: ImageDetail) => (img.imageTags ?? []).length === 0;
const rule1 = newestFirst.filter((img) => isUntagged(img) && (img.imagePushedAt?.getTime() ?? 0) < cutoff);
// Rule 2 counts every image but can't expire untagged ones (rule 1 has priority over them).
const rule2 = newestFirst.slice(keep).filter((img) => !isUntagged(img));
return [...rule1, ...rule2];
}
async function main(): Promise<void> {
const untaggedDays = Number(arg("--untagged-days") ?? 14);
const keep = Number(arg("--keep") ?? 30);
const apply = process.argv.includes("--apply");
const rows = [];
const targets: string[] = [];
let freeableGb = 0;
for await (const page of paginateDescribeRepositories({ client: ecr }, {})) {
for (const repo of page.repositories ?? []) {
const name = repo.repositoryName ?? "";
const images: ImageDetail[] = [];
for await (const imgPage of paginateDescribeImages({ client: ecr }, { repositoryName: name })) {
images.push(...(imgPage.imageDetails ?? []));
}
const gb = images.reduce((s, i) => s + (i.imageSizeInBytes ?? 0), 0) / 1e9;
const untagged = images.filter((i) => (i.imageTags ?? []).length === 0).length;
const policySet = await hasPolicy(name);
const expiring = policySet ? [] : estimate(images, untaggedDays, keep);
const expGb = expiring.reduce((s, i) => s + (i.imageSizeInBytes ?? 0), 0) / 1e9;
if (!policySet) {
targets.push(name);
freeableGb += expGb;
}
rows.push({
Repository: name,
Images: images.length,
Untagged: untagged,
GB: gb.toFixed(2),
"$/mo": (gb * ECR_PER_GB_MONTH).toFixed(2),
"Has policy": policySet ? "yes" : "NO",
"Would expire": policySet ? "-" : expiring.length,
"GB freed (est.)": policySet ? "-" : expGb.toFixed(2),
});
}
}
console.table(rows);
console.log(`${targets.length} repositories without a lifecycle policy. Estimated ${freeableGb.toFixed(2)} GB ` +
`(about $${(freeableGb * ECR_PER_GB_MONTH).toFixed(2)}/month) would expire.`);
if (!apply) {
console.log("Dry run: no policy was written. Policy that --apply would set:");
console.log(JSON.stringify(JSON.parse(policy(untaggedDays, keep)), null, 2));
return;
}
for (const repositoryName of targets) {
await ecr.send(new PutLifecyclePolicyCommand({ repositoryName, lifecyclePolicyText: policy(untaggedDays, keep) }));
console.log(`Lifecycle policy set on ${repositoryName}`);
}
console.log("ECR expires matching images within 24 hours. Check CloudTrail for the deletions.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
The estimate is a local approximation of ECR’s rules. It doesn’t know which untagged images a manifest list protects, so it can overcount for multi-architecture repositories. For the exact list, run a lifecycle policy preview before applying.
How do you run it?
npm install @aws-sdk/client-ecr
npm install --save-dev tsx typescript
# Dry run: report and print the policy it would set
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx set-ecr-lifecycle-policies.ts
# Keep 50 images, expire untagged after 7 days, and write the policies
AWS_PROFILE=platform-admin AWS_REGION=us-east-1 npx tsx set-ecr-lifecycle-policies.ts --untagged-days 7 --keep 50 --apply
Sample output
┌─────────┬───────────────┬────────┬──────────┬─────────┬────────┬────────────┬──────────────┬─────────────────┐
│ (index) │ Repository │ Images │ Untagged │ GB │ $/mo │ Has policy │ Would expire │ GB freed (est.) │
├─────────┼───────────────┼────────┼──────────┼─────────┼────────┼────────────┼──────────────┼─────────────────┤
│ 0 │ 'api' │ 412 │ 268 │ '96.41' │ '9.64' │ 'NO' │ 371 │ '88.02' │
│ 1 │ 'worker' │ 57 │ 12 │ '14.80' │ '1.48' │ 'NO' │ 31 │ '7.95' │
│ 2 │ 'base-images' │ 18 │ 0 │ '6.12' │ '0.61' │ 'yes' │ '-' │ '-' │
└─────────┴───────────────┴────────┴──────────┴─────────┴────────┴────────────┴──────────────┴─────────────────┘
2 repositories without a lifecycle policy. Estimated 95.97 GB (about $9.60/month) would expire.
Dry run: no policy was written. Policy that --apply would set:
The dry run then prints the policy JSON it would write. Figures are illustrative: api stores 96.41 GB (96.41 × $0.10 ≈ $9.64 a month), and 371 of its 412 images would expire.
How do you preview a lifecycle policy before applying it?
AWS recommends previewing a policy before applying it. In the console you save test rules and run the preview. From code, call StartLifecyclePolicyPreview with the repository name and policy text, then poll GetLifecyclePolicyPreview for the images it would expire. The preview doesn’t change the repository’s policy, so it’s a safe step between the dry run and --apply; it needs the ecr:StartLifecyclePolicyPreview and ecr:GetLifecyclePolicyPreview actions.
Two cases deserve care. Repositories whose tags are deployment pointers, such as prod or a Git SHA your Kubernetes manifests pin, need a count high enough to cover every version you might roll back to. And base-image repositories that other builds pull by digest may need a tagged rule with a tagPatternList instead of the blanket any rule. Images you keep for rollback still need checking for vulnerable packages, which the script to enable ECR image scanning on every repository handles.
Troubleshooting
- Images are still there an hour after
--apply. Expected: ECR expires images within 24 hours of them meeting the criteria. Each expiry shows up as an event in CloudTrail. InvalidParameterExceptionorValidationExceptiononPutLifecyclePolicy. Usually a rule that breaks the documented rules:countUnitset withimageCountMoreThan, ataggedrule with no pattern or prefix list, or ananyrule that isn’t last.AccessDeniedException. Check the repository ARN in the policy and whether the repository policy denies you. The walkthrough to troubleshoot AWS IAM access denied errors step by step applies.- A deploy fails with an image not found. The tag it references was expired. Raise
--keepfor that repository, or give its release tags their owntaggedrule.
Ask ChatWithCloud instead
ChatWithCloud can answer “Which ECR repositories have no lifecycle policy, and how much storage do they use?” by writing AWS SDK for JavaScript v2 code, running it on your machine with your AWS profile and explaining the result. It’s the same flow as asking it to explain why your AWS bill increased. Because it runs generated code without a confirmation step, a question like “add a lifecycle policy to all of them” would write policies immediately; ask for the report with a read-only AWS profile connected to ChatWithCloud and apply policies with the script. The ChatWithCloud security page covers what’s sent to the model.
Frequently asked questions
How do I delete untagged images in ECR automatically?
Add a lifecycle policy rule with tagStatus: "untagged", countType: "sinceImagePushed", countUnit: "days" and the age you want. ECR expires matching images within 24 hours.
Can an ECR lifecycle policy keep the last N images?
Yes. Use countType: "imageCountMoreThan" with countNumber set to N. Images are sorted newest first by push time and the rest are expired.
Can I delete ECR images that haven’t been pulled recently?
Not directly. sinceImagePulled only works with the transition action to archive storage; a sinceImageTransitioned rule can then expire them after at least 90 days in archive.
Does a lifecycle policy delete images that are in use?
ECR doesn’t know whether a container is running an image. It protects images referenced by a manifest list, but anything else that matches a rule is expired, so size your rules around what you deploy.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud