Clean Up Azure Container Registry: Retention Policies and az acr purge
Every CI pipeline that pushes to Azure Container Registry leaves something behind. After a few months the registry holds thousands of per-commit tags, a pile of untagged manifests nobody can name, and a storage number that keeps climbing. At some point you get a quota warning, a cost question, or a slow az acr repository show-tags call, and you need to clean up without deleting the image that production is running right now.
This guide walks through the whole process: measuring what you have, understanding the difference between tags and manifests, turning on the built-in retention policy, running acr purge safely, scheduling it, and locking release images so no automation can remove them. If you are new to the service, start with Introduction to Azure Container Registry and come back here once you have a registry with some history.
Why registries keep growing
A registry stores two kinds of things: manifests (identified by a sha256 digest) and the layer blobs they reference. A tag is just a movable pointer to a manifest. Growth comes from a few predictable patterns:
- Per-commit tags. Tagging every build with the commit SHA is good practice for traceability, but nothing ever removes those tags.
- Overwritten tags. When you push
orders-api:latestororders-api:devagain, the tag moves to the new manifest. The old manifest stays in the registry, now untagged. These "orphaned" manifests are invisible in most tooling and are often the biggest source of waste. - Multi-arch images and attestations. Image indexes, per-platform manifests, SBOMs and signatures are all stored as manifests or referrers. They are small individually but add up.
- Abandoned repositories. Feature-branch repositories and experiments that nobody deletes.
Layers are shared between manifests, so deleting one manifest frees only the layers no other manifest references. That is why storage often drops less than you expect after a cleanup.
Check registry usage first
Before deleting anything, get a baseline so you can measure the effect.
az acr show-usage --name crappsdemo --output table
The output shows current storage against the included storage for your tier, plus counts for webhooks and other limits. Tier limits change over time, so compare against the current SKU documentation rather than a number you remember.
Also check which SKU you are on, because it decides which cleanup features you can use:
az acr show --name crappsdemo --query "{name:name, sku:sku.name, loginServer:loginServer}" --output table
List repositories, tags and manifests
Start with the repository list, then drill into the ones that look large or noisy.
# All repositories
az acr repository list --name crappsdemo --output table
# Newest 20 tags in one repository, with timestamps
az acr repository show-tags \
--name crappsdemo \
--repository orders-api \
--orderby time_desc \
--top 20 \
--detail \
--query "[].{tag:name, updated:lastUpdateTime}" \
--output table
Tags only show part of the picture. To see manifests, including untagged ones, use az acr manifest list-metadata:
# All manifests in the repository with their tags
az acr manifest list-metadata \
--registry crappsdemo \
--name orders-api \
--orderby time_asc \
--query "[].{digest:digest, tags:tags, created:createdTime}" \
--output table
# Only untagged manifests
az acr manifest list-metadata \
--registry crappsdemo \
--name orders-api \
--query "[?tags==null].digest" \
--output tsv
If the second command returns hundreds of digests, you have found most of your wasted storage.
A quick script to count tags per repository helps decide where to focus:
for repo in $(az acr repository list --name crappsdemo --output tsv); do
count=$(az acr repository show-tags --name crappsdemo --repository "$repo" --query "length(@)" --output tsv)
echo "$repo $count"
done | sort -k2 -n -r
Delete tags vs delete manifests
This distinction is the most common source of confusion and the most common cause of lost images.
| Operation | Command | What is removed | Storage freed |
|---|---|---|---|
| Untag | az acr repository untag |
Only the tag pointer | Nothing; manifest stays (now possibly untagged) |
| Delete by tag | az acr repository delete --image repo:tag |
The manifest the tag points to, and every other tag on that manifest | Unique layers of that manifest |
| Delete by digest | az acr repository delete --image repo@sha256:... |
The manifest and all its tags | Unique layers of that manifest |
| Delete repository | az acr repository delete --repository repo |
Everything in the repository | All unique layers |
# Remove a tag but keep the image
az acr repository untag --name crappsdemo --image orders-api:feature-login
# Delete a manifest (and all tags that point to it)
az acr repository delete --name crappsdemo --image orders-api@sha256:3f1c...e9a2 --yes
The pitfall: az acr repository delete --image orders-api:1.4.0 deletes the manifest, not just the tag. If orders-api:stable pointed to the same digest, it is gone too. When you only want to remove a name, use untag. When you want the bytes gone, delete by digest so you know exactly which manifest you are removing.
Deleted manifests cannot be restored unless the soft delete policy (a preview feature at the time of writing) is enabled. Soft delete and the retention policy cannot be enabled together, so check the current docs before choosing between them.
Enable the retention policy for untagged manifests
For the orphaned-manifest problem, the simplest fix is the built-in retention policy. It automatically deletes untagged manifests after a number of days. It requires the Premium tier and has carried a preview label for a long time, so confirm its current status for your environment.
az acr config retention update \
--registry crappsdemo \
--status enabled \
--days 30 \
--type UntaggedManifests
az acr config retention show --registry crappsdemo
Things to know before relying on it:
- It only affects manifests that become untagged after the policy is enabled. Existing orphans are not cleaned up retroactively; use
acr purge --untaggedonce for those. - Setting
--days 0deletes untagged manifests almost immediately. That sounds attractive but removes your safety window. If a deployment references an image by digest and someone moves the tag, the running workload's image could disappear before anyone notices. - Manifests with
delete-enabledset tofalseare skipped. - It never touches tagged images, so per-commit tags still need
acr purge.
If you are on Basic or Standard, acr purge with --untagged gives you the same outcome on a schedule.
Run az acr purge safely
acr purge is a container command that runs inside ACR Tasks. You invoke it with az acr run --cmd, passing /dev/null as the source context because no build context is needed. It is available on all tiers.
The key flags:
| Flag | Meaning |
|---|---|
--filter |
repository:tag-regex. Can be repeated. The repository part is also a regex. |
--ago |
Only delete tags last updated before this duration, in Go duration format such as 30d or 2d3h6m. |
--keep |
Keep the latest N tags that match the filter, even if they are older than --ago. |
--untagged |
Also delete manifests that are untagged, including ones left untagged by this run. |
--dry-run |
Print what would be deleted without deleting anything. |
Always start with a dry run:
PURGE_CMD="acr purge \
--filter 'orders-api:^sha-.*' \
--ago 30d \
--keep 10 \
--untagged \
--dry-run"
az acr run \
--registry crappsdemo \
--cmd "$PURGE_CMD" \
/dev/null
Read the output carefully. Once the list matches what you expect, remove --dry-run and run it again. For large registries, raise the task timeout so the purge is not cut off halfway:
az acr run \
--registry crappsdemo \
--timeout 3600 \
--cmd "acr purge --filter 'orders-api:^sha-.*' --ago 30d --keep 10 --untagged" \
/dev/null
Some useful filter patterns:
--filter 'orders-api:.*' every tag in orders-api
--filter 'orders-api:^pr-.*' pull request builds only
--filter '.*:^feature-.*' feature-branch tags in all repositories
--filter 'samples/.*:.*' everything under the samples/ namespace
Pitfalls worth repeating:
- A broad filter such as
'.*:.*'combined with--ago 1ddeletes release tags too. Scope filters to tag prefixes you control. --keepcounts per repository among the tags matching the filter, not across the registry.- Purge does not know what is deployed. If a cluster still runs
orders-api:sha-4b2e91c, purge will delete it once it is older than--ago. Lock deployed versions or keep them out of the filter.
Schedule purge as an ACR task
A one-off purge fixes today's problem; a scheduled task keeps it fixed. Create a task with a cron trigger. Schedules are evaluated in UTC.
PURGE_CMD="acr purge \
--filter 'orders-api:^sha-.*' \
--filter 'orders-api:^pr-.*' \
--filter 'billing-worker:^sha-.*' \
--ago 30d \
--keep 10 \
--untagged"
az acr task create \
--name purge-ci-tags \
--registry crappsdemo \
--cmd "$PURGE_CMD" \
--schedule "0 2 * * Sun" \
--context /dev/null \
--timeout 3600
Verify it and trigger a manual run to confirm it works before waiting for Sunday:
az acr task show --name purge-ci-tags --registry crappsdemo --output table
az acr task run --name purge-ci-tags --registry crappsdemo
az acr task list-runs --registry crappsdemo --name purge-ci-tags --output table
az acr task logs --registry crappsdemo --run-id <run-id>
Keep the purge command in source control alongside your pipeline definitions, and update the task from there. A filter change is a destructive change and deserves a code review.
Lock the images you must keep
Locking is the safety net for everything above. An image or repository with delete-enabled set to false is skipped by acr purge, by the retention policy, and rejected by manual deletes.
# Lock a release tag: cannot be overwritten or deleted
az acr repository update \
--name crappsdemo \
--image orders-api:1.4.2 \
--write-enabled false \
--delete-enabled false
# Lock the underlying manifest by digest as well
az acr repository update \
--name crappsdemo \
--image orders-api@sha256:3f1c...e9a2 \
--delete-enabled false
# Protect a whole repository from deletion but still allow pushes
az acr repository update \
--name crappsdemo \
--repository orders-api \
--delete-enabled false \
--write-enabled true
# Inspect the attributes
az acr repository show --name crappsdemo --image orders-api:1.4.2
--write-enabled false on a tag prevents it from being moved to a different manifest, which is exactly what you want for a semantic version tag. Do not set it on moving tags like latest or stable, or your next release push will fail.
Make locking part of the release pipeline: when a version is promoted to production, the pipeline locks its tag and digest. When the version is retired, a deliberate step unlocks it.
A tagging strategy that makes cleanup safe
Cleanup is only as safe as your tag naming. The goal is that a regex can tell disposable tags from permanent ones.
| Tag pattern | Example | Purpose | Cleanup rule |
|---|---|---|---|
sha-<shortsha> |
sha-4b2e91c |
Every CI build | Purge after 30 days, keep 10 |
pr-<number> |
pr-218 |
Pull request previews | Purge after 7 days |
<semver> |
1.4.2 |
Releases | Never purged; locked when promoted |
stable, latest |
stable |
Moving pointers | Not purged; target is locked by digest |
A few rules make this work in practice:
- Never reuse a semantic version tag. Treat tags like
1.4.2as immutable and enforce it with--write-enabled false. - Deploy by immutable tag or digest, not by
latest. It makes rollbacks reliable and avoids surprises when an old manifest is removed. - Put experiments in a separate namespace, such as
sandbox/, and purge that namespace aggressively. - Review the purge filters whenever someone introduces a new tag prefix.
Related guides
- Introduction to Azure Container Registry
- ACR Tasks: Cloud Builds
- Push Docker Images to ACR with GitHub Actions
- Securing Azure Container Registry
Conclusion
Registry cleanup comes down to four moves: measure with az acr show-usage and az acr manifest list-metadata, remove orphaned manifests with the retention policy on Premium or acr purge --untagged on any tier, remove stale CI tags with a scheduled acr purge task, and lock anything that production depends on. Run every new filter with --dry-run first, keep the purge definition in source control, and design tag names so a regex can safely tell disposable builds from releases. Do that once and the registry stays small without anyone having to think about it.