• English icon
  • German icon
Explainer

Same tag, different image: Azure Container Registry explained

· Series: Containers on Azure

azureazure-container-registrydockeracr-tasksdevops

An app scales out. A new server starts, pulls inference-api:latest like all the others, and runs different code. Nobody deployed anything: someone pushed a new image under the same tag.

This explainer shows how Azure Container Registry (ACR) stores images, why a tag is not a version, how ACR Tasks builds and patches images in the cloud, and a tagging and cleanup scheme that keeps production predictable.

Key points

  • A tag is a label that can move. A digest is a fingerprint that never changes. Pushing an existing tag moves it to the new image; the old image stays in the registry, untagged.
  • ACR Tasks builds in Azure. az acr build uploads the source, builds the image and pushes it, without Docker on your machine. Tasks can also rebuild on a commit, on a schedule, or when the base image is patched.
  • Stable tags for base images, unique tags for deployments. latest stays out of production, and the deployed image gets a lock.
  • acr purge deletes the tags your filter selects, not only untagged images. For orphans only, use --untagged-only, and run every new purge with --dry-run first.

How images are stored

LevelWhat it holdsExample
Registryeverything, behind one address: the login servermyacr.azurecr.io
Repositoryall versions of one image; slashes group repositories by team or environmentproduction/inference-api
Artifacta container image, a Helm chart or another OCI artifactinference-api:v1.2.0

Permissions can be scoped to a repository or a namespace prefix, so a team only reaches its own images.

An image is a stack of layers. Every Dockerfile instruction that changes files adds one. Layers are stored by their content: when ten images share the same Python base layer, the registry keeps it once, and a server that already has it doesn't download it again. On top sits the manifest, which lists every layer and is identified by a digest, a SHA-256 hash.

The three tiers (Basic, Standard, Premium) push and pull the same way. Premium adds features for larger setups, such as geo-replication to other regions and private endpoints.

Tags and digests

A tag such as v1.2.0 or latest is a readable name. One image can carry several tags, for example 1.2.0 and stable. But a tag can move: push a new image with an existing tag, and the tag jumps to the new image. That is what happened to the new server above. And if you leave the tag out completely, Docker uses latest.

A digest is calculated from the content, so it can never point to another image:

# By tag: whatever the tag points to right now
docker pull myacr.azurecr.io/inference-api:v1.2.0
# By digest: always exactly this image
docker pull myacr.azurecr.io/inference-api@sha256:<digest>

Tags are for people. Digests are for guarantees.

Builds in the cloud with ACR Tasks

Building on your own machine and pushing works, until five developers build the same image on five laptops. A quick task moves the build into Azure:

az acr build --registry myacr \
--image inference-api:v1.0.0 .

The last argument is the build context: a local folder, a Git repository or a remote tarball. The command uploads it, runs the Docker build in Azure and pushes the result, like docker build plus docker push. Every build runs in the same environment, and ACR Tasks builds images for Linux, Windows and ARM.

A quick smoke test runs a container from the registry in the cloud and shows its output:

az acr run --registry myacr \
--cmd '$Registry/inference-api:v1.0.0 python --version' \
/dev/null

/dev/null means "no source context". $Registry stands for your registry; without it, the image name would be looked up on Docker Hub.

Builds that start themselves

A task created with az acr task create can start on its own:

TriggerStarts a build whenGood to know
Source codea commit lands in a GitHub or Azure DevOps repositorypull request builds are possible, off by default
Timera cron expression matchesthe schedule is in UTC
Base imagethe base image of your app is updatedon by default; the task learns the base image on its first run
az acr task create --registry myacr --name build-api \
--image 'inference-api:{{.Run.ID}}' \
--context https://github.com/org/api.git#main \
--file Dockerfile --git-access-token $PAT \
--schedule "0 0 * * *"

The base image trigger saves the most work. When python:3.12-slim gets a security patch, every image built on it is out of date; ACR Tasks rebuilds them automatically. It works with base images in the same registry, in another Azure container registry, or in public repositories on Docker Hub and the Microsoft Container Registry. A base image in an Azure registry triggers the rebuild right away; public base images are checked every 10 to 60 minutes. Only the base image of the final stage is tracked.

One condition: the base image must keep a stable tag, like 3.12-slim. If the patch arrives under a new tag, nothing is triggered.

For more than one step, a task is written in YAML. This one builds the image, runs the tests inside it, and pushes only at the end:

version: v1.1.0
steps:
- build: -t $Registry/inference-api:$ID .
- cmd: $Registry/inference-api:$ID python -m pytest
- push:
- $Registry/inference-api:$ID

The steps run in order, and a failed step fails the whole run, so an image with failing tests is never pushed. $ID is the run ID: every build gets its own unique tag.

Tags for production

Stable tagsUnique tags
Reused?yes, they move to every new versionnever
Examples1 (newest 1.x), 1.1 (newest patch of 1.1)build ID, timestamp, commit hash
Right forbase images: patches flow indeployments: every server pulls the same image, a rollback is the previous tag

The build ID is usually the best unique tag, because it leads straight back to the pipeline run with its logs and test results. A commit hash can repeat: a base image rebuild uses the same commit.

The rule: stable tags for base images, unique tags for deployments, and latest stays out of production.

Then protect what is running. A locked image can't be overwritten or deleted by accident until it is unlocked:

az acr repository update --name myacr \
--image inference-api:v1.2.0 --write-enabled false

Cleaning up without deleting production

Every moved tag leaves an untagged image behind, and over time these orphans fill the registry and the bill. The cleanup tool is acr purge (in preview), which runs as a task inside the registry, on demand or on a schedule.

The common mistake: the filter selects tags. Purge deletes every matching tag older than --ago, and --untagged removes untagged images in addition. So --filter 'inference-api:.*' --untagged --ago 30d deletes every tag in that repository older than 30 days, including the one in production, unless it is locked.

For the orphans only, and as a preview first:

az acr run --registry myacr \
--cmd "acr purge --untagged-only --ago 7d --dry-run" \
/dev/null

On the Premium tier, a retention policy deletes untagged images automatically after a number of days (still in preview). One warning for both: a system that pulls by digest may need an image that has no tag, and cleanup deletes untagged images. Unique tags avoid that problem.

Further reading

Video transcript

Your app scales out. A new server starts and pulls the same image as the others: inference-api, tag latest. But the new server runs different code than the rest. Nobody deployed anything. Someone just pushed.

In this video, you will see how Azure Container Registry stores images, why a tag is not the same as a version, how to build images in the cloud and keep them patched, and a tagging scheme that keeps production predictable.

Start with the structure. Azure Container Registry is a private registry for container images, run by Azure. At the top is the registry itself. It has one address, called the login server: your registry name, followed by azurecr.io. Inside the registry are repositories. A repository holds all versions of one image, for example inference-api. Repository names can contain slashes, like production/inference-api, or ml-team/model-server. These namespaces group images by team or environment. And access can be granted per repository, so a team only reaches its own images. Inside a repository are the artifacts: container images, but also Helm charts and other OCI artifacts. Now look inside one image. It is a stack of layers. Each instruction in your Dockerfile that changes files adds a layer. Layers are stored by their content. So when ten images share the same Python base layer, the registry keeps that layer only once. And a server that already has the layer doesn't download it again. On top of the layers sits the manifest. It lists every layer of the image, and it has a digest: a SHA-256 hash that identifies it. Registries come in three tiers: Basic, Standard and Premium. Pushing and pulling work the same in all of them. Premium adds features for larger setups, like geo-replication to other regions and private endpoints.

Now the most important idea in this video. A tag is a sticky note. A digest is a fingerprint. A tag, like version 1.2.0 or latest, is a readable name for an image. One image can carry several tags at once, for example 1.2.0 and stable. But tags can move. When you push a new image with a tag that already exists, the tag jumps to the new image. The old image stays in the registry, but now no tag points to it. It is untagged. That is exactly what happened at the start of this video. Someone pushed a new image as latest. The new server pulled latest a few minutes later, and got the new image. There is one more trap. If you leave out the tag completely, Docker uses latest automatically. A digest works differently. It is calculated from the content, so it can never point to another image. Pull by digest, with an @ sign and the SHA-256 hash, and you get exactly the same image every time. So: tags are for people. Digests are for guarantees.

So how do images get into the registry? The classic way is to build on your own machine with Docker, and push. That works, until five developers build the same image on five different laptops. ACR Tasks moves the build into Azure. The simplest form is a quick task, with one command: az acr build. You give it the registry, an image name with a tag, and a build context. The context is where your source files are: a local folder, a Git repository, or a remote tarball. The command uploads the context, runs the Docker build in Azure, and pushes the result to your registry. Like docker build plus docker push, but you don't need Docker on your machine at all. Every build runs in the same environment, no matter who starts it. And ACR Tasks can build images for Linux, Windows and ARM. You can also run a container from your registry in the cloud. az acr run takes an image and a command, for example python --version, and shows you the output. A quick smoke test before anything is deployed.

Quick tasks run when you call them. A task created with az acr task create can also start on its own. There are three kinds of triggers. First, a source code trigger. Point the task at a Git repository in GitHub or Azure DevOps, and every commit starts a build. Builds for pull requests are possible too, but they are off by default. Second, a timer. A cron expression in UTC, for example every night at midnight. And third, the trigger that saves the most work: the base image trigger. Your Dockerfile starts with a FROM line, for example python, tag 3.12-slim. When that base image gets a security patch, every image built on top of it is out of date. ACR Tasks learns the base image of your app the first time the task runs. From then on, when the base image is updated, it rebuilds your app automatically. This works when the base image is in the same registry, in another Azure container registry, or in a public repository on Docker Hub or the Microsoft Container Registry. A base image in an Azure registry triggers the rebuild right away. Public base images are checked every ten to sixty minutes. One condition matters: the base image must keep a stable tag, like 3.12-slim. If the patch arrives under a new tag, nothing is triggered. For more than one step, you describe the task in YAML. A multi-step task can build the image, run the tests inside it, and push it as the last step. A failed step fails the whole run, so an image with failing tests is never pushed. And the run ID gives every build its own unique tag.

That brings us back to tags, because there are two kinds, and each one has its job. Stable tags are reused. Think of them as version channels. Tag 1 always points to the newest version 1. Tag 1.1 points to the newest patch of 1.1. Stable tags are right for base images, because they let patches flow in. That is exactly what the base image trigger needs. Unique tags are never reused. Every push gets a new one: a build ID, a timestamp, or a commit hash. The build ID is usually the best choice, because it leads straight back to the pipeline run, with its logs and test results. A commit hash can repeat, because a base image rebuild uses the same commit. Unique tags are right for deployments. Every server pulls exactly the same image, and a rollback is just the previous tag. So the rule is simple: stable tags for base images, unique tags for deployments. And latest stays out of production. One more step protects what is running. Lock the deployed image by setting write-enabled to false. Now nobody can overwrite or delete that tag by accident, until you unlock it.

Every moved tag leaves something behind: an untagged image. Over time, these orphans fill your registry and your bill. The cleanup tool is acr purge. It runs as a task inside your registry, on demand or on a schedule. But read the filter carefully, because this is a common mistake. The filter selects tags. Purge deletes every matching tag that is older than the age you set. The untagged option removes untagged images in addition to those tags. So a filter for all tags in inference-api, with an age of thirty days, deletes every tag in that repository that is older than thirty days. Including the one in production, unless it is locked. If you only want the orphans, use the --untagged-only option. And run every new purge command with --dry-run first. It shows what would be deleted, without deleting anything. On the Premium tier, a retention policy can do this for you. It deletes untagged images automatically after a number of days you choose. It is still in preview. One warning: if a system pulls images by digest, the image it needs might have no tag at all, and cleanup deletes untagged images. Unique tags avoid that problem.

To sum it up. A registry holds repositories, and repositories hold images built from shared layers. A tag is a sticky note that can move. A digest is a fingerprint that never changes. ACR Tasks builds in the cloud, and rebuilds on a commit, on a schedule, or when the base image is patched. Stable tags for base images, unique tags for deployments, and a lock on what runs in production. And clean up with care, because purge deletes the tags your filter selects.

Now you know where your images live, and how to keep them under control. If this was useful, subscribe for more practical .NET and Azure.

Need this in your own Azure environment?

I build, migrate and run .NET platforms on Azure, with Azure DevOps and Terraform. If your team wants this set up properly, see what I offer.

See services