Clean Up Azure Container Registry: Retention Policies and az acr purge

TQ
Tran Quang
September 14, 2026 · 11 min read
#Azure#Azure Container Registry#Docker#DevOps

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:latest or orders-api:dev again, 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.

bash
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:

bash
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.

bash
# 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:

bash
# 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:

bash
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
bash
# 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.

bash
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 --untagged once for those.
  • Setting --days 0 deletes 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-enabled set to false are 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:

bash
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:

bash
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:

text
--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 1d deletes release tags too. Scope filters to tag prefixes you control.
  • --keep counts 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.

bash
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:

bash
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.

bash
# 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.2 as 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.

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.

Found this useful?

Share it with someone who might need it.

Related articles All articles