ECR unused image storage cost is the line that grows while compute shrinks. Private registry storage is $0.10 per GB-month on the Amazon ECR pricing page. That is cheap until CI has been retagging latest for three years and every digest is still in the repo. In-region pulls to ECS, EKS, Fargate, and Lambda are free. Cross-region replication and NAT-path pulls are not. The first lever is still bytes sitting in the registry that no cluster will ever pull again.
We see mid-market SaaS registries at 1–4 TB with no lifecycle policy. That is $100–$400 / month of storage alone — often more than the Fargate bill for the same services — plus Inspector enhanced-scan charges on images nobody runs. The cleanup we deliver is policy, not a weekend batch-delete-image.
Why ECR Unused Image Storage Cost Dominates
ECR does not overwrite a digest when you push a new app:latest. The previous manifest loses the tag and remains as an untagged image. Layer storage is deduplicated per repository, not across repositories, so the same Ubuntu base copied into twelve microservice repos is paid twelve times.
The usual generators:
- CI that always pushes
:${GIT_SHA}and retags:latest/:prodwithout expiring old SHAs. - Mutable tags on every branch build (
:pr-123) never expired. - Failed or abandoned
docker pushretries that left extra manifests. - “Promote by retag” pipelines that never delete the source tag’s previous digest.
# Storage by repository — start here, not with a console screenshot
aws ecr describe-repositories --query 'repositories[].repositoryName' --output text \
| tr '\t' '\n' | while read -r repo; do
bytes=$(aws ecr describe-images --repository-name "$repo" \
--query 'sum(imageDetails[].imageSizeInBytes)' --output text)
echo "$bytes $repo"
done | sort -n
imageSizeInBytes is the compressed manifest size AWS reports per image; it over-counts shared layers if you sum blindly. Use it to rank repos, then confirm with Cost Explorer usage type ECR-Storage (and replication transfer if you have CRR). The scorecard we hand a CFO is GB by repo, last pull, untagged count, not a raw sum of every digest.
Lifecycle Rules Before Anyone Deletes by Hand
Amazon ECR lifecycle policies expire or archive images that match a rule, usually within 24 hours of qualifying. Preview first. We never attach a policy we have not previewed against production tags. Official behavior is in Automate the cleanup of images.
The policy we ship on application repos:
{
"rules": [
{
"rulePriority": 1,
"description": "Keep current prod/stage mutable tags from lower-priority expiry",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["prod", "stage", "latest"],
"countType": "imageCountMoreThan",
"countNumber": 999999
},
"action": { "type": "expire" }
},
{
"rulePriority": 2,
"description": "Expire untagged images after 14 days",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 14
},
"action": { "type": "expire" }
},
{
"rulePriority": 3,
"description": "Keep last 20 SHA builds; expire older tagged SHAs",
"selection": {
"tagStatus": "tagged",
"tagPatternList": ["sha-*", "build-*"],
"countType": "imageCountMoreThan",
"countNumber": 20
},
"action": { "type": "expire" }
}
]
}
Priority 1 is a preserve rule: an image that matches a higher-priority rule cannot be expired by a lower-priority rule. That is the documented evaluation model, not a workaround. Without it, a tagStatus: any rule will eat prod.
We set 14 days on untagged — long enough to debug a bad retag, short enough that CI churn does not become a second compute bill. SHA retention is a count, not an age, so a hot repo does not keep a year of builds and a cold repo does not wipe its last known-good.
aws ecr start-lifecycle-policy-preview --repository-name app --lifecycle-policy-text file://policy.json
aws ecr get-lifecycle-policy-preview --repository-name app
# only then
aws ecr put-lifecycle-policy --repository-name app --lifecycle-policy-text file://policy.json
Registry Storage Should Not Outrun Compute
After lifecycle, the next levers are architectural:
- One base-image repo, referenced by digest from app Dockerfiles, instead of rebaking Ubuntu in every service repo. Dedup is per repository.
- Multi-stage builds so the pushed image is the runtime stage, not the compiler stage.
- In-region pulls via ECR interface endpoints so image pulls do not traverse NAT. That is the same pattern as VPC endpoints vs NAT — pull traffic is often the largest NAT line after S3.
- Replicate only Regions you deploy. Cross-region ECR transfer is billed; a warm-standby Region that nobody pulls from is a second full copy at $0.10/GB-month plus transfer.
- Enhanced scanning on images you run. Inspector on every forgotten digest is a security bill attached to storage waste.
We attach the lifecycle policy as a Terraform aws_ecr_lifecycle_policy on the repo module so a new service does not ship without expiry. Tag policy (immutable on SHA tags, mutable only for prod/stage) is a separate control — immutability does not expire anything; it only stops silent overwrite.
This is the same hygiene pass as Lambda cost strategies (drop unused versions) and belongs with Kubernetes / containerization delivery, not a one-off console cleanup.
Need a registry scorecard (GB, untagged, last pull, estimated monthly storage)? 907-841-8407 or contact@rutagon.com.
Ask for an ECR unused image storage cost cleanup →
Frequently Asked Questions
Does deleting a tag delete the image and stop storage charges?
No. Untagging leaves the digest in the repository. Storage ends when the image is expired (lifecycle) or explicitly deleted. Retagging latest is how most unused image storage is created.
Will a lifecycle policy remove the image our cluster is running?
Not if that digest is still tagged with a preserve prefix (prod, stage) or is inside the SHA keep-count. Preview the policy. If production pulls by digest only and the tag was already dropped, add a preserve rule for that digest’s remaining tag or retag before attach.
Why is Cost Explorer storage lower than the sum of imageSizeInBytes?
ECR bills stored unique layers per repository. describe-images sums per-manifest sizes and double-counts shared layers. Rank with the API, bill with ECR-Storage. Do not forecast from a naive sum.
Should we archive instead of expire?
Archive (when available on the repo) is a middle path for images you might need for forensics but will not deploy. It is not a substitute for expiring untagged CI noise. We expire untagged; we archive only named release tags past the keep window when legal wants a longer image history.
Do in-region ECS and EKS pulls cost ECR data transfer?
No. In-region pulls to AWS compute are not charged as ECR data transfer. You can still pay NAT data processing if the pull path goes through a NAT Gateway instead of an ECR VPC endpoint. Storage and NAT are separate levers.