ECR Lifecycle Policy Not Deleting Images: the 24-Hour Rule, Priority Stealing, Untagged vs Tagged, Two Patterns Mean AND, Manifest Lists, imageCountMoreThan and the Preview Command
An ECR lifecycle policy that is not deleting images is usually doing exactly what its rules say, which is not what you meant. Lifecycle policies run on their own schedule, so the first answer is patience: images that meet a rule's criteria are expired within 24 hours, not at the moment you save the policy. After that, the misses come from four rules of evaluation that are easy to get backwards: an image can be expired by only one rule, and a higher-priority rule that merely sees an image stops every lower-priority rule from expiring it; a rule listing two tag patterns matches images that carry both tags, not either; a rule with tagStatus: any must have the highest priority number (it runs last); and an image that is part of a multi-architecture manifest list cannot be expired until the list itself goes. Here is the one that surprises people who have done everything right: imageCountMoreThan counts only the images that rule selects, so "keep 10, tagged release-*" keeps 10 releases and never touches the 400 untagged layers filling the repository. Below: the preview command that shows what a policy would do before you wait a day, each miss with its fix, archive storage, replicated repositories, and the policy shape that cleans a typical CI repository.
The shop's booking app builds an image on every commit, and Ethan added a lifecycle policy in August: "expire images older than 30 days." Two months later the repository bill had doubled and describe-images listed 612 images. The policy showed as active. Nothing had expired. Jake asked the question that led to the answer: "Older than 30 days with what name?" Ethan's rule said tagged images with the prefix v. The 600 images the pipeline pushed were untagged, because the tag moved to the newest build each time, leaving the old ones with no tag at all. "So the rule was looking for labels," Jake said, "and the junk has no labels."
New to ECR? Our plain-English ECR guide explains repositories, tags and digests in about ten minutes. The commands below use the AWS CLI; on Windows Command Prompt, replace the line-ending \ with ^.
How a lifecycle policy decides, in five rules
A lifecycle policy is a list of rules. Each rule selects images (by tag status, tag pattern, and either a count or an age) and applies an action (expire, or move to archive storage). The evaluator does not run the rules one after another the way you might read them. It works like this:
- Every rule is evaluated at the same time against every image, and each rule marks the images it selects.
- The marks are then applied in priority order, lowest
rulePrioritynumber first. - Each image is expired by one rule or none. Once a higher-priority rule has identified an image (even if that rule decided to keep it), no lower-priority rule may expire it.
- Older images go first. Within a rule, expiry is ordered by push time, oldest to newest.
- The rule with
tagStatus: anyruns last, so it must carry the highest priority number.
Rule 3 is the one that explains most "my rule never fires" reports. It does not say "a lower rule cannot delete what a higher rule deleted"; it says a lower rule cannot delete what a higher rule saw. If rule 1 keeps the newest 10 prod* images, those 10 are protected from every later rule, which is what you want. But rule 1 also saw the older prod* images it decided to expire, and a later rule that would also have matched them has nothing left to do.
There is also a timing clause: when a policy is created, or when an image newly meets a rule's criteria, the action happens within 24 hours. The console shows the policy as active immediately; the deletions arrive later, logged in CloudTrail as they happen. If you changed the policy this morning, the fairest test is tomorrow morning, and the fastest test is the preview.
"So it's a sorting office," Jake said. "Every rule looks at every parcel, and the first rule to touch a parcel owns it." "And a parcel with no label," said Ethan, "is only touched by a rule that asks for unlabelled parcels."
See what the policy would do today: the preview
Before you wait a day, or apply anything to a production repository, run the preview. It evaluates a policy text against the repository now and lists every image that would be expired or archived, with the rule that would do it, without changing anything.
# start a preview of the policy in policy.json against the repository
aws ecr start-lifecycle-policy-preview --repository-name shop-booking --lifecycle-policy-text file://policy.json
# a few seconds later: status and the images it would act on
aws ecr get-lifecycle-policy-preview --repository-name shop-booking \
--query "{status: status, total: summary.expiringImageTotalCount, images: previewResults[].[appliedRulePriority, action.type, imagePushedAt, imageTags]}" --output table
The status moves from IN_PROGRESS to COMPLETE; expiringImageTotalCount is the number that would go; and appliedRulePriority tells you which rule claimed each image, which is how you catch priority stealing. If the preview shows zero images for a rule you expected to fire, the problem is in the rule, and the sections below find it. If the preview shows the right images and the live policy still has not removed them, you are inside the 24-hour window or looking at a replicated copy (see replication).
In the console the same thing is Repositories > your repository > Actions > Lifecycle policies > Edit test rules > Save and run test, with Apply as lifecycle policy when the result is what you intended. The console's test rules are separate from the live policy, so you can test a change without touching what is running.
Miss 1: the rule says tagged, the junk is untagged
This is Ethan's case and the most common one. A CI pipeline that pushes shop-booking:latest (or :main) on every build does not create 600 tagged images. It moves one tag from the old image to the new one, and the old image stays behind with no tag. A repository like that is one tagged image and hundreds of untagged ones, and a rule with "tagStatus": "tagged" never looks at the untagged ones.
# how many images are tagged vs untagged? aws ecr describe-images --repository-name shop-booking --filter tagStatus=UNTAGGED --query "length(imageDetails)" aws ecr describe-images --repository-name shop-booking --filter tagStatus=TAGGED --query "length(imageDetails)"
The fix is a rule for untagged images, usually by age, because an untagged image is normally a superseded build that nothing references after a day or two:
{
"rulePriority": 1,
"description": "Expire untagged images after 7 days",
"selection": { "tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 7 },
"action": { "type": "expire" }
}
Two cautions. A running task or pod may be using an untagged image by digest (it pulled :latest last week; the tag has moved on, but the container still runs that digest). Expiring the image does not stop the running container, but it does stop that exact image from being pulled again, for example when a node is replaced. Seven days is a common compromise; if your deployments pin digests, keep the window longer than your longest rollback. And a policy may contain only one rule that selects untagged images per storage class, so put all your untagged logic in that one rule.
Miss 2: a higher-priority rule already claimed the images
When a rule that should fire shows zero images in the preview while another rule shows many, priority is stealing. The classic shape is a broad rule placed before a narrow one:
rule 1: tagged beta* keep newest 1 rule 2: tagged prod* keep newest 1 image A: [beta-1, prod-1], image B: [beta-2, prod-2], image C: [beta-3]
Rule 1 sees all three images (they all carry a beta tag), keeps C, and expires A and B. Rule 2 wanted to keep the newest prod image, which was B, but B was already seen by rule 1, and rule 2 cannot save it. Swap the priorities and rule 1 (prod*) keeps B, expires A, and rule 2 (beta*) can only expire what is left, which is nothing it is allowed to touch except C, which it keeps. Same rules, different result, decided entirely by the numbers.
The habit that avoids it: put the rules that protect things first (keep the last N releases, keep prod*), and the rules that clean up last (untagged by age, then any). Use the preview's appliedRulePriority column to confirm each image is being claimed by the rule you expect.
Miss 3: two tag patterns in one rule mean AND, not OR
A rule with "tagPatternList": ["alpha*", "beta*"] does not select images tagged alpha or beta. It selects images that carry both an alpha* tag and a beta* tag. An image tagged only alpha-3 is not matched and is never expired by that rule. The same is true for tagPrefixList.
If you mean "either," write two rules, one per pattern, with different priorities. If you mean "images that were tagged with both the build number and the environment," one rule with both patterns is exactly right. The preview makes the difference obvious: an AND rule in an OR situation matches almost nothing.
The split version of the rule above, written the way most teams actually want it:
- Rule 10:
tagPatternList ["alpha*"], keep the newest 5, expire the rest. - Rule 20:
tagPatternList ["beta*"], keep the newest 5, expire the rest. - Run the preview and check that each rule's
appliedRulePriorityshows up in the results; if rule 20 shows nothing, an image carrying both tags has been claimed by rule 10 (see priority).
Remember the other constraint on the same list: tag prefixes must be unique across the rules of a storage class, so you cannot write two rules both selecting prod* with different ages. Pick one selection per rule and one rule per selection.
Miss 4: imageCountMoreThan counts the rule's selection, not the repository
imageCountMoreThan: 10 does not mean "keep the repository at 10 images." It means: of the images this rule selects, sort them newest to oldest and expire everything after the tenth. A rule selecting release-* keeps 10 releases and says nothing about untagged images, feature-branch images or anything else. Jake's 612-image repository is the same trap from the other side: its only rule ever considered images tagged v*, so the 600 untagged builds were never counted, by age or by number.
If you want a hard cap on the whole repository, that is the job of the tagStatus: any rule, placed last: "any image, imageCountMoreThan 100" keeps the newest 100 of whatever the earlier rules did not already claim, and expires the rest.
Miss 5: prefix or pattern, and the four-wildcard limit
There are two ways to select tags, and mixing them up selects the wrong images:
| Selector | Value | Matches | Does not match |
|---|---|---|---|
tagPrefixList | ["prod"] | prod, prod-1, production | myprod |
tagPatternList | ["prod"] | only exactly prod | prod-1, production |
tagPatternList | ["prod*"] | prod, prod-1, production-team1 | myprod |
tagPatternList | ["*prod*"] | repo-production, prod-team | release |
A pattern without a * is an exact match, which is the usual way a "working" prefix rule stops working when someone converts it to a pattern. A rule may use one selector or the other, not both, and a pattern may contain at most four wildcards. Tags are case-sensitive throughout.
Miss 6: the image belongs to a manifest list (multi-arch builds)
If you build with docker buildx for more than one platform, each push creates an image index (a manifest list) plus one image per architecture, and in ECR the per-architecture images show up as untagged entries. A lifecycle rule that targets untagged images will select them, the preview will list them, and they still will not be deleted, because an image referenced by a manifest list cannot be expired until the manifest list is deleted or archived first. The policy is not broken; it is protecting the tagged index from losing its parts.
Two ways to handle it. Let the rules expire the tagged index on its own schedule (for example, keep the newest 20 release indexes); once an index goes, its child images lose their reference and the untagged rule expires them within the next day. Or, if you need them gone now, delete the index by tag and the children follow:
# which untagged images are children of an index? (they share the index's digest as a parent; look for media type "image.index" on the parent) aws ecr describe-images --repository-name shop-booking --image-ids imageTag=v1.4.2 --query "imageDetails[].[imageManifestMediaType, imageDigest]" # delete the index by tag; the per-arch images become expirable aws ecr batch-delete-image --repository-name shop-booking --image-ids imageTag=v1.4.2
The same protection applies to reference artifacts such as signatures and SBOMs attached to an image: they are expired within 24 hours of their subject image going, not before.
Archive rules: sinceImagePulled only archives, and 90 days is the floor
Lifecycle policies can also move images to ECR's archive storage class instead of deleting them, which is cheaper for images you must keep but rarely pull. The four count types, and which action each one allows:
| countType | Means | countUnit | Allowed action |
|---|---|---|---|
imageCountMoreThan | keep the newest N of the rule's selection | none | expire or transition |
sinceImagePushed | pushed more than N days ago | days | expire or transition |
sinceImagePulled | not pulled for N days (never pulled = push time) | days | transition to archive only |
sinceImageTransitioned | in archive for N days (needs storageClass: archive) | days | expire only |
countNumber must be a positive whole number; zero is rejected. Three rules trip people here:
sinceImagePulled("not pulled in N days") can only be used with thetransitionaction. A rule that tries toexpireimages by last pull is rejected. To delete by pull activity, archive first, then expire from archive withsinceImageTransitioned.- Archived images have a 90-day minimum. A lifecycle rule cannot expire an image that has been archived for fewer than 90 days;
batch-delete-imagecan, but you pay the 90 days anyway. - An image never pulled counts its push time as its last pull, so brand-new images are not archived the day after they arrive.
{ "rulePriority": 5, "description": "Archive anything not pulled for 90 days",
"selection": { "tagStatus": "any", "countType": "sinceImagePulled", "countUnit": "days", "countNumber": 90 },
"action": { "type": "transition", "targetStorageClass": "archive" } },
{ "rulePriority": 6, "description": "Delete after a year in archive",
"selection": { "tagStatus": "any", "storageClass": "archive", "countType": "sinceImageTransitioned", "countUnit": "days", "countNumber": 365 },
"action": { "type": "expire" } }
Replicated repositories keep their own copies
If you replicate repositories to other Regions or accounts, a lifecycle policy acts only in the Region and account where it was created. The source repository empties on schedule; the replicas keep every image ever replicated, and their storage bill grows quietly. Create the same policy in each destination (repository creation templates can do this automatically for new repositories), and check describe-images in the replica Region when the source looks clean but the bill does not.
Cross-account replication has the same shape with one extra step: the destination account owns the replica repository, so the policy must be put there with that account's credentials, and the destination's CloudTrail is where the deletions are logged. A quick way to compare is the image count per Region:
for r in us-east-1 eu-west-1 ap-south-1; do echo -n "$r: "; aws ecr describe-images --region $r --repository-name shop-booking --query "length(imageDetails)" done
A policy that cleans a CI repository
This is the shape that works for most build pipelines: protect releases, clean superseded builds, cap the rest. Priorities leave gaps so you can insert rules later:
{
"rules": [
{ "rulePriority": 10, "description": "Keep the newest 30 release images",
"selection": { "tagStatus": "tagged", "tagPatternList": ["v*"], "countType": "imageCountMoreThan", "countNumber": 30 },
"action": { "type": "expire" } },
{ "rulePriority": 20, "description": "Feature and PR builds: expire after 14 days",
"selection": { "tagStatus": "tagged", "tagPatternList": ["pr-*"], "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 14 },
"action": { "type": "expire" } },
{ "rulePriority": 30, "description": "Untagged (superseded) images: expire after 7 days",
"selection": { "tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 7 },
"action": { "type": "expire" } },
{ "rulePriority": 100, "description": "Hard cap: never more than 200 images of any kind",
"selection": { "tagStatus": "any", "countType": "imageCountMoreThan", "countNumber": 200 },
"action": { "type": "expire" } }
]
}
# preview, then apply
aws ecr start-lifecycle-policy-preview --repository-name shop-booking --lifecycle-policy-text file://policy.json
aws ecr get-lifecycle-policy-preview --repository-name shop-booking --query "{status: status, total: summary.expiringImageTotalCount}"
aws ecr put-lifecycle-policy --repository-name shop-booking --lifecycle-policy-text file://policy.json
# confirm what is live
aws ecr get-lifecycle-policy --repository-name shop-booking --query lifecyclePolicyText --output text | python -m json.tool
Read it against the evaluation rules: the release rule comes first so releases are protected from everything below; the pr-* rule and the untagged rule only see what rule 10 did not claim; and the any rule has the highest number and runs last. If your pipeline tags with latest or a branch name, add a protective rule for it above the untagged rule, or the image behind latest is safe (it is tagged) but the one you may want to roll back to is not.
Ethan's repository went from 612 images to 41 by the next morning: 30 releases, a few PR builds under two weeks old, and the last week of untagged builds. The bill followed a month later. Jake's contribution was the description on rule 30, which now reads "the junk with no labels."
The same policy in Terraform and CloudFormation
Most "the policy works in the test but is not executed" reports come from repositories managed by infrastructure as code, where the policy was applied once and never previewed. The resource is separate from the repository, which is why a Terraform plan that creates the repository can forget the policy entirely:
resource "aws_ecr_lifecycle_policy" "shop_booking" {
repository = aws_ecr_repository.shop_booking.name
policy = jsonencode({
rules = [
{ rulePriority = 10, description = "Keep the newest 30 release images",
selection = { tagStatus = "tagged", tagPatternList = ["v*"], countType = "imageCountMoreThan", countNumber = 30 },
action = { type = "expire" } },
{ rulePriority = 30, description = "Untagged images: expire after 7 days",
selection = { tagStatus = "untagged", countType = "sinceImagePushed", countUnit = "days", countNumber = 7 },
action = { type = "expire" } },
{ rulePriority = 100, description = "Hard cap",
selection = { tagStatus = "any", countType = "imageCountMoreThan", countNumber = 200 },
action = { type = "expire" } }
]
})
}
In CloudFormation the policy is the LifecyclePolicy property of AWS::ECR::Repository, with LifecyclePolicyText holding the same JSON as a string. Two habits keep both honest: run start-lifecycle-policy-preview against the rendered JSON in the pipeline before apply, and remember that "works in test" means the preview found the images; the deletions still arrive within 24 hours, not at apply time. If the test keeps finding images and nothing ever goes, compare get-lifecycle-policy with the file you think is deployed; a second module or a console edit may have replaced it.
For IT admins: making lifecycle policies predictable
- Preview before every change, and keep the policy JSON in version control next to the pipeline that pushes to the repository.
- Protect first, clean last: keep-N release rules at the top, untagged and
anyat the bottom, with priority gaps. - Tag what you want to keep (releases, the current deploy) and let everything else become untagged; the untagged rule is your main cleaner.
- Turn on tag immutability for release repositories so a tag cannot be moved onto a different image, which keeps "keep newest N tagged" honest.
- Replicate the policy wherever you replicate the repository, and watch the ECR storage line in Cost Explorer per Region.
- Check CloudTrail for lifecycle actions when you need to prove what was deleted and when.
Frequently asked questions
Why is my ECR lifecycle policy not deleting images?
Most often the rule selects tagged images while the pile is untagged, a higher-priority rule already claimed the images, two tag patterns in one rule require both tags, the images belong to a multi-arch manifest list, or less than 24 hours have passed. The lifecycle policy preview shows which.
How long does an ECR lifecycle policy take to run?
Images are expired or archived within 24 hours after they meet a rule's criteria. The policy shows as active immediately, but deletions arrive on ECR's schedule, logged in CloudTrail.
How do I test an ECR lifecycle policy before applying it?
Run aws ecr start-lifecycle-policy-preview with the policy text, then get-lifecycle-policy-preview, which lists every image that would be expired or archived and the rule that claims it. In the console, use Edit test rules and Save and run test.
Does imageCountMoreThan apply to the whole repository?
No. It applies to the images the rule selects. "Keep 10" on a rule for release-* keeps 10 releases and ignores untagged and other images. Use a final tagStatus any rule for a repository-wide cap.
Why does my lifecycle policy ignore untagged images?
Because its rules say tagStatus tagged. Add a rule with tagStatus untagged, usually sinceImagePushed with a few days. Only one rule per storage class may select untagged images.
Does a rule with two tag patterns match either tag?
No. With multiple patterns or prefixes in one rule, an image must carry all of them to be selected. For "either," write one rule per pattern with different priorities.
Why can't the policy delete images from a multi-arch build?
Per-architecture images referenced by a manifest list cannot be expired until the manifest list is deleted or archived. Expire the tagged index first; the children follow within a day.
What does rule priority do in an ECR lifecycle policy?
All rules are evaluated together, then applied lowest number first. An image is expired by at most one rule, and a lower-priority rule cannot expire an image a higher-priority rule identified. The tagStatus any rule must have the highest number.
What is the difference between tagPrefixList and tagPatternList?
A prefix matches tags that start with the value; a pattern matches with wildcards, and without a * it is an exact match. A rule can use one or the other, with at most four wildcards per pattern.
Can a lifecycle policy delete images by last pull date?
Not directly. sinceImagePulled only works with the transition action to archive storage. To delete by pull activity, archive first, then expire with sinceImageTransitioned after at least 90 days.
Why are my replicated repositories still full?
A lifecycle policy acts only in the Region and account where it was created. Create the same policy on each replica repository, or use a repository creation template so new replicas get it automatically.
Does expiring an image stop containers that are running it?
No. Running containers keep their image. But the expired image can no longer be pulled, so a new node or a restart that must pull it will fail; keep the untagged window longer than your rollback horizon.
How do I see which rule would delete an image?
The appliedRulePriority field in get-lifecycle-policy-preview names the rule that claims each image. Zero images for a rule you expected usually means a higher-priority rule claimed them.
Can I delete images immediately instead of waiting?
Yes, with aws ecr batch-delete-image and the image tags or digests. For images in archive storage under 90 days, you are still charged the 90-day minimum.
How many lifecycle rules can a policy have, and must priorities be sequential?
Priorities must be unique positive integers but need not be sequential; leaving gaps (10, 20, 30) makes later inserts easy. Rules for the same storage class must use distinct tag prefixes, and only one may select untagged images.
Does the lifecycle policy delete the tag or the image?
The image, identified by digest, along with its tags. A tag is just a pointer; moving a tag to a newer image leaves the old image untagged, which is what the untagged rule is for.
For readers preparing for an AWS certification
We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.
Exams that test this topic: DevOps Engineer – Professional (DOP-C02) • CloudOps Engineer – Associate (SOA-C03) • Developer – Associate (DVA-C03 from December 1, 2026).
Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:
1. A repository fills up with untagged images from every build. How do you clean it automatically?
An ECR lifecycle policy rule for untagged images with sinceImagePushed, for example seven days.
2. How do you see what a lifecycle policy will delete before applying it?
Run a lifecycle policy preview (StartLifecyclePolicyPreview), which lists the images each rule would expire.
3. Which ECR setting stops a release tag from being moved to a different image?
Tag immutability on the repository.
4. Images must be available in a second Region for disaster recovery. What do you configure?
ECR replication rules (cross-Region, and cross-account if needed). Lifecycle policies must be added in each destination separately.
5. Which ECR feature scans images for software vulnerabilities?
Image scanning: basic scanning, or enhanced scanning with Amazon Inspector for continuous scans.
6. A pipeline needs a private ECR repository created automatically with the right settings. What helps?
Repository creation templates, which apply settings such as lifecycle policies to repositories created by replication or pull-through cache.
Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.
The policy JSON now lives in the booking app's repository, next to the workflow that pushes the images, with a comment above rule 30 in Jake's words. The preview runs in the pipeline whenever the file changes, and the output is posted to the pull request. Nobody has asked why the bill went up since.
📌 If you keep one line from this page
A lifecycle policy runs within 24 hours and expires each image by at most one rule: protect releases first, clean untagged images with their own rule, cap with a final any rule, and run the preview before you wait a day.
Two patterns = AND · imageCountMoreThan counts the rule's selection · manifest-list children go after the index.
Revision note. Written October 17, 2026. "A place for everything, and everything in its place," the proverb says; a lifecycle policy only tidies the places you name.