Skip to content

Instantly share code, notes, and snippets.

@sajayantony
Created February 18, 2026 18:55
Show Gist options
  • Select an option

  • Save sajayantony/22a03bf38d2a1a1e2b9c66ff530ca5c7 to your computer and use it in GitHub Desktop.

Select an option

Save sajayantony/22a03bf38d2a1a1e2b9c66ff530ca5c7 to your computer and use it in GitHub Desktop.
ACR Purge Task: Keep last N images per repository (Bicep + Bash workflow)

ACR Image Purge — Keep Last N Images

Automated cleanup for Azure Container Registry repositories. Deploys a scheduled ACR task that retains only the last N images per repository and deletes the rest.

How It Works

The ACR task uses the built-in acr purge command with:

Flag Value Purpose
--filter 'demo/purge/.*:.*' Namespace wildcard — matches all repos under demo/purge/
--ago 0d Consider all images regardless of age
--keep 3 Retain the 3 most recent images per repository

Note: --untagged is intentionally omitted to avoid removing multi-arch sub-manifests.

Files

File Description
acr-purge-task.bicep Bicep template to deploy the scheduled purge task
acr-purge-workflow.sh End-to-end bash script: seeds repos, creates task, runs & verifies

Option 1: Deploy with Bicep (Recommended)

# Deploy the purge task
az deployment group create \
  --resource-group <RESOURCE_GROUP> \
  --template-file acr-purge-task.bicep \
  --parameters \
    registryName=<REGISTRY_NAME> \
    keepCount=3 \
    repoFilter='demo/purge/.*:.*'

# Trigger a manual run to verify
az acr task run \
  --name purge-keep-3 \
  --registry <REGISTRY_NAME> \
  --resource-group <RESOURCE_GROUP>

Bicep Parameters

Parameter Default Description
registryName (required) Name of the existing ACR
keepCount 3 Number of images to retain per repo
repoFilter demo/purge/.*:.* Regex filter for repositories and tags
schedule 0 0 * * * Cron schedule (default: daily at midnight UTC)
taskName purge-keep-<N> Name of the ACR task

Option 2: Deploy with Azure CLI

# Namespace wildcard — single filter covers all repos under a prefix
az acr task create \
  --name purge-keep-3 \
  --registry <REGISTRY_NAME> \
  --resource-group <RESOURCE_GROUP> \
  --cmd "acr purge --filter 'demo/purge/.*:.*' --ago 0d --keep 3" \
  --schedule "0 0 * * *" \
  --context /dev/null

# Per-repo filters — use when repos don't share a prefix
az acr task create \
  --name purge-keep-3 \
  --registry <REGISTRY_NAME> \
  --resource-group <RESOURCE_GROUP> \
  --cmd "acr purge \
    --filter 'demo/purge/web:.*' \
    --filter 'demo/purge/api:.*' \
    --filter 'demo/purge/worker:.*' \
    --ago 0d --keep 3" \
  --schedule "0 0 * * *" \
  --context /dev/null

Dry Run

Always test before executing:

az acr run \
  --registry <REGISTRY_NAME> \
  --resource-group <RESOURCE_GROUP> \
  --cmd "acr purge --filter 'demo/purge/.*:.*' --ago 0d --keep 3 --dry-run" \
  /dev/null

End-to-End Test Workflow

The acr-purge-workflow.sh script demonstrates the full lifecycle:

  1. Seeds 3 repositories (demo/purge/web, demo/purge/api, demo/purge/worker) with 10 images each
    • web and api use az acr build with a minimal Dockerfile
    • worker uses oras copy to re-tag a single image (no build needed)
  2. Creates the scheduled purge task
  3. Dry-runs to preview what would be deleted
  4. Executes the purge
  5. Verifies that exactly 3 tags remain per repository
export REGISTRY_NAME="<your-registry>"
export RESOURCE_GROUP="<your-resource-group>"
bash acr-purge-workflow.sh

Filter Syntax

Pattern Matches
demo/purge/.*:.* All repos under demo/purge/ namespace, all tags
myapp:.* All tags in the myapp repo
myapp:^v\d+ Tags starting with v followed by digits in myapp
.*:.* Every repo and every tag in the registry
// ============================================================================
// ACR Purge Task — Bicep Template
// ============================================================================
// Deploys a scheduled ACR task that keeps only the last N images per repository
// under a given namespace prefix. Runs daily at midnight UTC.
//
// Deploy:
// az deployment group create \
// --resource-group <rg> \
// --template-file acr-purge-task.bicep \
// --parameters registryName=<registry> keepCount=3 repoFilter='demo/purge/.*:.*'
// ============================================================================
@description('Name of the existing Azure Container Registry')
param registryName string
@description('Number of most-recent images to keep per repository')
@minValue(1)
param keepCount int = 3
@description('Repository filter (regex). Use a namespace wildcard like "demo/purge/.*:.*" to match all repos under a prefix, or a single repo like "myapp:.*".')
param repoFilter string = 'demo/purge/.*:.*'
@description('Cron schedule for the purge task (default: daily at midnight UTC)')
param schedule string = '0 0 * * *'
@description('Task name')
param taskName string = 'purge-keep-${keepCount}'
@description('Location — defaults to the resource group location')
param location string = resourceGroup().location
// Encode the ACR task YAML as base64
// The YAML content:
// version: v1.1.0
// steps:
// - cmd: acr purge --filter '<repoFilter>' --ago 0d --keep <keepCount>
// disableWorkingDirectoryOverride: true
// timeout: 3600
var taskYaml = 'version: v1.1.0\nsteps:\n - cmd: acr purge --filter \'${repoFilter}\' --ago 0d --keep ${keepCount}\n disableWorkingDirectoryOverride: true\n timeout: 3600\n'
var encodedTask = base64(taskYaml)
resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = {
name: registryName
}
resource purgeTask 'Microsoft.ContainerRegistry/registries/tasks@2019-06-01-preview' = {
parent: acr
name: taskName
location: location
properties: {
status: 'Enabled'
platform: {
os: 'Linux'
architecture: 'amd64'
}
agentConfiguration: {
cpu: 2
}
timeout: 3600
step: {
type: 'EncodedTask'
encodedTaskContent: encodedTask
values: []
}
trigger: {
timerTriggers: [
{
name: 'daily-purge'
schedule: schedule
status: 'Enabled'
}
]
baseImageTrigger: {
name: 'defaultBaseimageTriggerName'
baseImageTriggerType: 'Runtime'
status: 'Enabled'
}
}
}
}
output taskId string = purgeTask.id
output taskName string = purgeTask.name
#!/bin/bash
#
# ACR Image Purge Workflow
# ========================
# This script demonstrates how to:
# 1. Seed 3 repositories with 10 test images each
# 2. Create a scheduled ACR task that keeps only the last 3 images per repo
# 3. Verify the purge worked correctly
#
# Prerequisites:
# - Azure CLI (az) installed and logged in
# - oras CLI installed (for the worker repo seeding via copy)
# - An existing Azure Container Registry
#
# Usage:
# export REGISTRY_NAME="<your-registry>"
# export RESOURCE_GROUP="<your-resource-group>"
# bash acr-purge-workflow.sh
set -euo pipefail
###############################################################################
# Configuration — set these or export them before running
###############################################################################
REGISTRY_NAME="${REGISTRY_NAME:?Set REGISTRY_NAME}"
RESOURCE_GROUP="${RESOURCE_GROUP:?Set RESOURCE_GROUP}"
LOGIN_SERVER="${REGISTRY_NAME}.azurecr.io"
REPOS=("demo/purge/web" "demo/purge/api" "demo/purge/worker")
IMAGE_COUNT=10 # number of images to push per repo
KEEP_COUNT=3 # number of images to retain after purge
TASK_NAME="purge-keep-${KEEP_COUNT}"
###############################################################################
# Step 1 — Seed repositories with test images
###############################################################################
echo "=== Step 1: Seeding repositories ==="
# --- Repos 1 & 2: az acr build (no local Docker required) ---
TMPDIR=$(mktemp -d)
cat > "${TMPDIR}/Dockerfile" <<'EOF'
FROM mcr.microsoft.com/mcr/hello-world
EOF
for repo in "${REPOS[0]}" "${REPOS[1]}"; do
echo "--- Seeding ${repo} via az acr build ---"
for i in $(seq 1 "${IMAGE_COUNT}"); do
echo " Building ${repo}:v${i} ..."
az acr build \
--registry "${REGISTRY_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--image "${repo}:v${i}" \
--file "${TMPDIR}/Dockerfile" \
"${TMPDIR}" \
--no-logs -o none
done
echo " ✔ ${repo} seeded with ${IMAGE_COUNT} images"
done
rm -rf "${TMPDIR}"
# --- Repo 3: oras copy (re-tags an existing image, no build needed) ---
# oras picks up Azure identity automatically; no separate login required.
repo="${REPOS[2]}"
echo "--- Seeding ${repo} via oras copy ---"
# Build a single base image first
TMPDIR=$(mktemp -d)
cat > "${TMPDIR}/Dockerfile" <<'EOF'
FROM mcr.microsoft.com/mcr/hello-world
EOF
az acr build \
--registry "${REGISTRY_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--image "${repo}:v1" \
--file "${TMPDIR}/Dockerfile" \
"${TMPDIR}" \
--no-logs -o none
rm -rf "${TMPDIR}"
# Copy v1 → v2..v10 using oras
for i in $(seq 2 "${IMAGE_COUNT}"); do
echo " Copying ${repo}:v1 → :v${i} ..."
oras copy "${LOGIN_SERVER}/${repo}:v1" "${LOGIN_SERVER}/${repo}:v${i}"
done
echo " ✔ ${repo} seeded with ${IMAGE_COUNT} images"
echo ""
echo "=== Verify: all repos should have ${IMAGE_COUNT} tags ==="
for repo in "${REPOS[@]}"; do
tags=$(az acr repository show-tags --name "${REGISTRY_NAME}" --repository "${repo}" --orderby time_asc -o tsv)
count=$(echo "${tags}" | wc -l | tr -d ' ')
echo " ${repo}: ${count} tags"
done
###############################################################################
# Step 2 — Create the scheduled purge task
###############################################################################
echo ""
echo "=== Step 2: Creating ACR purge task ==="
# Option A — Namespace wildcard (single filter covers all repos under demo/purge/)
# Use this when all target repos share a common path prefix.
echo "Creating task with namespace wildcard filter: 'demo/purge/.*:.*'"
az acr task create \
--name "${TASK_NAME}" \
--registry "${REGISTRY_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--cmd "acr purge --filter 'demo/purge/.*:.*' --ago 0d --keep ${KEEP_COUNT}" \
--schedule "0 0 * * *" \
--context /dev/null \
-o table
# Option B (alternative) — Per-repo filters
# Uncomment below and comment out Option A if repos don't share a prefix.
# az acr task create \
# --name "${TASK_NAME}" \
# --registry "${REGISTRY_NAME}" \
# --resource-group "${RESOURCE_GROUP}" \
# --cmd "acr purge \
# --filter 'demo/purge/web:.*' \
# --filter 'demo/purge/api:.*' \
# --filter 'demo/purge/worker:.*' \
# --ago 0d --keep ${KEEP_COUNT}" \
# --schedule "0 0 * * *" \
# --context /dev/null \
# -o table
###############################################################################
# Step 3 — Dry run (verify what would be deleted)
###############################################################################
echo ""
echo "=== Step 3: Dry run ==="
az acr run \
--registry "${REGISTRY_NAME}" \
--resource-group "${RESOURCE_GROUP}" \
--cmd "acr purge --filter 'demo/purge/.*:.*' --ago 0d --keep ${KEEP_COUNT} --dry-run" \
/dev/null
###############################################################################
# Step 4 — Execute the purge task
###############################################################################
echo ""
echo "=== Step 4: Running purge task ==="
az acr task run \
--name "${TASK_NAME}" \
--registry "${REGISTRY_NAME}" \
--resource-group "${RESOURCE_GROUP}"
###############################################################################
# Step 5 — Verify only KEEP_COUNT tags remain per repo
###############################################################################
echo ""
echo "=== Step 5: Verification ==="
all_pass=true
for repo in "${REPOS[@]}"; do
tags=$(az acr repository show-tags --name "${REGISTRY_NAME}" --repository "${repo}" --orderby time_asc -o tsv)
count=$(echo "${tags}" | wc -l | tr -d ' ')
echo " ${repo}: ${count} tags — $(echo ${tags} | tr '\n' ', ')"
if [ "${count}" -ne "${KEEP_COUNT}" ]; then
echo " ✘ FAIL: expected ${KEEP_COUNT} tags"
all_pass=false
fi
done
echo ""
if [ "${all_pass}" = true ]; then
echo "✔ SUCCESS: All repositories have exactly ${KEEP_COUNT} tags."
else
echo "✘ FAILURE: Some repositories do not have ${KEEP_COUNT} tags."
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment