The symptom
Every CI build pushes a new tag, nothing ever deletes one, and after a year the registry holds tens of thousands of images. You notice it as a storage line on the bill, a registry close to its SKU's included storage, or slow az acr repository show-tags calls. The fix is a repeatable purge job, not a one-off manual delete.
This guide uses acr purge, which runs inside ACR Tasks, so you don't need Docker or a build agent.
1. Measure what is there
Check the registry size against what the SKU includes:
az acr show-usage --name myregistry --output table
List repositories and the newest tags in the noisiest ones:
az acr repository list --name myregistry --output table
az acr repository show-tags --name myregistry --repository orders-api \
--orderby time_desc --detail --output table | head -20
Untagged manifests are the hidden part. Each time you push orders-api:latest again, the previous image loses its tag but stays in storage:
az acr manifest list-metadata --registry myregistry --name orders-api \
--query "[?tags==null].{digest:digest, created:createdTime}" --output table
Property names in this output can vary between CLI versions; run it once without --query to see what your build returns.
2. Know what deleting actually does
| Action | Command | Storage freed? |
|---|---|---|
| Remove one tag | az acr repository untag | No. The manifest stays as an untagged manifest |
| Delete an image by tag | az acr repository delete --image repo:tag | Yes, once no other manifest uses the layers. Deletes every tag pointing at that manifest |
| Delete by digest | az acr repository delete --image repo@sha256:... | Same as above |
| Purge tags only | acr purge --filter ... --ago ... | Partly. Untagged manifests remain unless you add --untagged |
| Purge tags and untagged manifests | acr purge ... --untagged | Yes |
Layers are shared between images, so freed storage is often smaller than the sum of the image sizes. The usage figure can take a while to drop after a large delete.
Caution
Deleted images can't be pulled again. A Kubernetes node that has to re-pull a deleted image, or a pod pinned to a deleted digest, goes into
ImagePullBackOffon the next restart or scale-out. Always check what is running before you purge.
3. Find the images still in use
On AKS, list every image and digest currently running:
kubectl get pods -A -o jsonpath='{range .items[*]}{range .status.containerStatuses[*]}{.image}{"\t"}{.imageID}{"\n"}{end}{end}' \
| sort -u | grep myregistry.azurecr.io
Also check the places that don't show up in a pod list:
- Deployment manifests and Helm values in Git for rollback targets.
- App Service, Container Apps and Functions that pull from the registry (
az webapp config container show,az containerapp show). - Image tags referenced by release pipelines for rollback.
4. Protect release images with locks
acr purge skips any image whose delete-enabled or write-enabled attribute is false. Lock the images you must keep:
az acr repository update --name myregistry \
--image orders-api:1.14.2 --delete-enabled false --write-enabled false
# Check the attributes
az acr repository show --name myregistry --image orders-api:1.14.2 \
--query changeableAttributes
A simpler pattern is a tag naming convention: CI builds push build-<number> tags, releases push semantic version tags, and the purge filter only matches build- tags.
5. Dry-run the purge
acr purge takes a repository and a tag regex in --filter, an age in --ago (Go duration format, with d for days, such as 30d or 2d6h), and optionally --keep for the number of most recent matching tags to retain. Run it from Bash or Cloud Shell:
PURGE_CMD="acr purge \
--filter 'orders-api:^build-.*' \
--filter 'billing-worker:^build-.*' \
--ago 30d --keep 10 --untagged --dry-run"
az acr run --cmd "$PURGE_CMD" --registry myregistry /dev/null
The /dev/null is the build context; the command needs none. The output lists every tag and manifest that would be deleted. Read it against the in-use list from step 3 before going further.
Tip
Start with one repository and a long
--agovalue (90d), check the result, then tighten. A filter of'.*:.*'matches every repository and every tag; don't use it on a shared registry until you have reviewed the dry-run.
6. Run it, then schedule it
Remove --dry-run and give large registries more time than the default task timeout (600 seconds):
PURGE_CMD="acr purge \
--filter 'orders-api:^build-.*' \
--filter 'billing-worker:^build-.*' \
--ago 30d --keep 10 --untagged"
az acr run --cmd "$PURGE_CMD" --registry myregistry --timeout 3600 /dev/null
Then create a scheduled task so it keeps happening (cron in UTC):
az acr task create --name purge-build-tags \
--cmd "$PURGE_CMD" \
--schedule "0 2 * * *" \
--registry myregistry \
--timeout 3600 \
--context /dev/null
# Trigger one run now and check the result
az acr task run --name purge-build-tags --registry myregistry
az acr task list-runs --registry myregistry --name purge-build-tags --output table
Read a run's log with az acr task logs --registry myregistry --run-id <run-id>.
7. Retention policy for untagged manifests
On Premium registries there is also a retention policy that deletes untagged manifests after a number of days. It is a preview feature; check its current status before relying on it:
az acr config retention update --registry myregistry \
--status enabled --days 7 --type UntaggedManifests
az acr config retention show --registry myregistry
It doesn't touch tagged images, so you still need a purge task for old build tags.
Common problems
| Symptom | Likely cause | Fix |
|---|---|---|
| Task run fails with an access or network error | Registry denies public network access, ACR Tasks can't reach it | Check the ACR Tasks network guidance for your setup (trusted services access or a dedicated agent pool on Premium) |
| Purge ends with a timeout | Too many tags for one run | Raise --timeout, split by repository, or run more often |
| Storage barely drops after purge | Layers shared with images you kept, or untagged manifests not purged | Add --untagged, then recheck usage later |
| An image you needed is gone | Filter regex too broad, or the image had no lock | Rebuild from the commit or restore from another registry; lock release tags from now on |
Pods in ImagePullBackOff after purge | Running workload pinned to a deleted tag or digest | Redeploy with a current tag; add in-use checks to the purge process |
Prevent it next time
- Separate tag namespaces:
build-*for CI, semantic versions for releases. - Lock every release image as part of the release pipeline.
- Keep the purge as a scheduled ACR task, defined in the same IaC repo as the registry, not a script on someone's laptop.
- Alert on registry storage growth.
StorageUsedis a registry metric in Azure Monitor; names can vary, so check the metric list in the portal. - Review the dry-run output whenever you change the filters.