Azure Container Registry Best Practices for CI/CD
Azure Container Registry stores the images your pipelines build and your services pull. Poor registry hygiene — unbounded image growth, weak access control, no vulnerability scanning — creates security and cost problems that are painful to fix after the fact. This page covers the practices that keep your ACR clean, secure, and fast.
Image naming and tagging conventions
Before writing any pipeline code, establish a naming convention. Consistent image names and tags make it easy to trace a running container back to its source commit, understand what version is deployed where, and clean up old images safely.
A recommended tag scheme uses three tags per build:
- Commit SHA. Immutable. Traces back to the exact code. Format:
sha-a1b2c3d4 - Build number. Sequential. Useful for ordering without reading git history. Format:
build-1234 - Branch or environment. Mutable. Used by deployment configurations that track “latest dev” or “latest main”. Format:
main,dev
# Tag and push with multiple tags in CI
SHORT_SHA=$(git rev-parse --short=8 HEAD)
BUILD_ID=$(Build.BuildId) # substitute your actual build ID variable
REGISTRY="myregistry.azurecr.io"
IMAGE="myapp/api"
# Login
az acr login --name myregistry
# Build with all three tag patterns
docker build \
-t "${REGISTRY}/${IMAGE}:sha-${SHORT_SHA}" \
-t "${REGISTRY}/${IMAGE}:build-${BUILD_ID}" \
-t "${REGISTRY}/${IMAGE}:main" \
.
# Push all tags
docker push "${REGISTRY}/${IMAGE}:sha-${SHORT_SHA}"
docker push "${REGISTRY}/${IMAGE}:build-${BUILD_ID}"
docker push "${REGISTRY}/${IMAGE}:main"
Provisioning ACR with the right tier and settings
# Terraform — ACR with Premium tier for production CI/CD
resource "azurerm_container_registry" "main" {
name = "myappregistry"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
sku = "Premium"
admin_enabled = false # never enable admin account in production
# Enable geo-replication to West US 2
georeplications {
location = "westus2"
zone_redundancy_enabled = true
tags = {}
}
# Enable content trust for signed images
trust_policy {
enabled = true
}
# Require HTTPS
network_rule_set {
default_action = "Deny"
ip_rule {
action = "Allow"
ip_range = "0.0.0.0/0" # tighten this to your pipeline agent IP ranges
}
}
tags = {
environment = "production"
managed_by = "terraform"
}
}
# Grant the pipeline Service Principal push access
resource "azurerm_role_assignment" "pipeline_push" {
scope = azurerm_container_registry.main.id
role_definition_name = "AcrPush"
principal_id = var.pipeline_sp_object_id
}
# Grant AKS cluster pull access
resource "azurerm_role_assignment" "aks_pull" {
scope = azurerm_container_registry.main.id
role_definition_name = "AcrPull"
principal_id = azurerm_kubernetes_cluster.main.kubelet_identity[0].object_id
}
Retention policies and image cleanup
A busy CI pipeline can push dozens of images per day. Without cleanup, a Basic or Standard ACR fills up within months. There are two tools for managing ACR storage: retention policies (for untagged manifests) and ACR Tasks with acr purge (for tagged images).
# Enable retention policy for untagged manifests (Premium tier only)
# Untagged manifests older than 7 days are automatically deleted
az acr config retention update \
--name myregistry \
--status enabled \
--days 7 \
--type UntaggedManifests
# Show current retention policy
az acr config retention show --name myregistry
# Manually purge tagged images using acr purge (run via ACR Tasks)
# This deletes images tagged with pattern "build-*" older than 30 days
az acr run \
--registry myregistry \
--cmd "acr purge --filter 'myapp/api:build-.*' --ago 30d --keep 5 --untagged" \
/dev/null
# Schedule the purge to run daily at 2 AM UTC using an ACR Task
az acr task create \
--registry myregistry \
--name cleanup-old-builds \
--schedule "0 2 * * *" \
--cmd "acr purge --filter 'myapp/api:build-.*' --ago 30d --keep 10 --untagged" \
--context /dev/null
The —keep 5 flag in acr purge ensures the 5 most recent images matching the filter are never deleted, even if they are older than the age threshold. Always set a keep count to prevent accidentally deleting all images of a type.
Vulnerability scanning with Microsoft Defender for Containers
Microsoft Defender for Containers scans ACR images for known CVEs (Common Vulnerabilities and Exposures) whenever an image is pushed. Findings appear in Microsoft Defender for Cloud. You can also integrate scanning directly into your pipeline with tools like Trivy to gate deployments on scan results.
# Enable Defender for Containers on the subscription (covers all ACR instances)
az security pricing create \
--name Containers \
--tier Standard
# In a pipeline: scan with Trivy and fail the build on HIGH/CRITICAL CVEs
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--exit-code 1 \
--severity HIGH,CRITICAL \
--format table \
"myregistry.azurecr.io/myapp/api:sha-${SHORT_SHA}"
# Generate a SARIF report for Azure Security Center integration
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $(pwd)/results:/results \
aquasec/trivy:latest image \
--format sarif \
--output /results/trivy-results.sarif \
"myregistry.azurecr.io/myapp/api:sha-${SHORT_SHA}"
# Publish Trivy SARIF results to Azure Pipelines security tab
- task: PublishBuildArtifacts@1
displayName: 'Publish Trivy SARIF results'
inputs:
pathToPublish: '$(System.DefaultWorkingDirectory)/results'
artifactName: 'security-scan-results'
ACR webhooks and automated tasks
ACR webhooks fire an HTTP POST to a URL when a specific event occurs (push, delete, quarantine). Use webhooks to trigger downstream actions: deploy a new image version to a test environment, notify a Slack channel, or start an Azure Container Apps revision update.
# Create a webhook that fires when any image is pushed
az acr webhook create \
--registry myregistry \
--name deploy-on-push \
--uri "https://myfunctionapp.azurewebsites.net/api/deploy-trigger?code=xxx" \
--actions push \
--scope "myapp/api:main"
# Test the webhook manually
az acr webhook ping \
--registry myregistry \
--name deploy-on-push
# List recent webhook deliveries and their status
az acr webhook list-events \
--registry myregistry \
--name deploy-on-push
Private endpoints for ACR in pipeline environments
In production environments where you want to block public internet access to your registry, configure ACR with a private endpoint. Pipeline agents that need to push to or pull from the registry must run inside the same VNet or a peered VNet.
# Terraform — ACR with private endpoint
resource "azurerm_private_endpoint" "acr" {
name = "acr-private-endpoint"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
subnet_id = azurerm_subnet.private_endpoints.id
private_service_connection {
name = "acr-privateserviceconnection"
private_connection_resource_id = azurerm_container_registry.main.id
subresource_names = ["registry"]
is_manual_connection = false
}
private_dns_zone_group {
name = "acr-dns-zone-group"
private_dns_zone_ids = [azurerm_private_dns_zone.acr.id]
}
}
resource "azurerm_private_dns_zone" "acr" {
name = "privatelink.azurecr.io"
resource_group_name = azurerm_resource_group.main.name
}
Common mistakes
- Enabling the admin account. The ACR admin account gives full registry access with a single username and password. It cannot be scoped, it cannot be rotated without coordination, and it is a single point of compromise. Use RBAC with the AcrPush and AcrPull roles instead and keep the admin account disabled.
- Not scheduling image cleanup. A CI pipeline that pushes images on every commit accumulates thousands of tagged images. Without a scheduled purge task, your ACR storage grows without bound. Set up a daily ACR Task to purge old build images, keeping only the last N builds.
- Pulling images without specifying a digest. When a deployment references
image:latest, there is no guarantee that the same image is pulled on every replica or restart —latestis mutable. In production Kubernetes or Container Apps deployments, reference images by digest (image@sha256:abc123) or by an immutable tag (commit SHA) to ensure reproducible deployments.
Summary
- Tag every image with an immutable commit SHA tag, a sequential build number tag, and a mutable branch tag — each serves a different purpose.
- Never enable the ACR admin account. Grant pipelines the AcrPush role and services the AcrPull role via RBAC.
- Schedule
acr purgeACR Tasks to delete old images on a daily basis. Enable the untagged manifest retention policy to clean up orphaned layers. - Integrate vulnerability scanning (Trivy or Defender for Containers) into your pipeline to gate deployments on scan results.
- In production environments, configure private endpoints to prevent public internet access to your registry.
Frequently asked questions
Which ACR tier should I use for CI/CD?
Basic is fine for small teams learning the platform. Standard adds 100 GB storage and 10 webhooks — enough for most production workloads. Premium adds geo-replication, private link support, content trust, and a 500 GB storage quota. Use Premium if you need to serve images in multiple regions or need private endpoint connectivity.
How do I automatically delete old images from ACR?
Use ACR retention policies to automatically delete untagged manifests after a set number of days. For tagged images, use ACR Tasks with the acr purge command on a schedule to delete images by tag pattern and age. This prevents unbounded storage growth in busy CI pipelines.
How do I authenticate a Kubernetes cluster to pull from a private ACR?
For AKS, the easiest method is to attach the ACR directly to the cluster using az aks update --attach-acr. This grants the AKS kubelet managed identity the AcrPull role on the registry — no imagePullSecret is needed. For other clusters, create an AcrPull service principal and store its credentials as a Kubernetes secret.