3. Architecture
This page makes the invisible machinery visible: what actually crosses the wire during a push and a pull, how the Entra token exchange works, where the control-plane/data-plane line falls, how geo-replication behaves when it's behind, and what breaks.
The shape of the thing
ACR is three loosely-coupled systems wearing one name:
- A control-plane resource in ARM — the registry's SKU, network rules, policies, identity
config, replications, tasks. Reached at
management.azure.com, governed by Azure RBAC roles likeOwnerandContributor. - A data-plane endpoint at
<name>.azurecr.io— an OCI Distribution API implementation. This is whatdocker,containerd,helm, andorastalk to. Governed byAcrPull/AcrPush/AcrDelete, and by tokens. - A blob backend — Microsoft-managed storage in the registry's region, invisible to you, holding the deduplicated layer blobs. By default the data plane redirects clients to it.
[Image Prompt: 2D minimalistic architecture diagram of Azure Container Registry showing an ARM control plane governing the registry resource on one side and an azurecr.io data plane endpoint serving manifests and redirecting to Microsoft-managed blob storage on the other, with separate RBAC role sets labelled on each side, flat design, clean vector art style, white background]
Authentication: the Entra token exchange
This is the part with no ECR analogue, and it's what az acr login is actually doing.
- You authenticate to Microsoft Entra ID — interactively, or as a service principal, or as a managed identity (the identity endpoint on the VM/pod/app hands out a token with no secret involved). You now hold an Entra access token for the ARM audience.
- You exchange it at the registry. The client
POSTs that token tohttps://<registry>.azurecr.io/oauth2/exchange. ACR validates it against Entra, checks your Azure RBAC assignments on the registry resource, and returns an ACR refresh token — scoped to this one registry, and comparatively short-lived (hours ⚠️ verify current lifetime). az acr loginwrites that refresh token into your Docker config under the login server, with the sentinel username00000000-0000-0000-0000-000000000000. That's why your~/.docker/config.jsonentry for ACR eventually stops working while a Docker Hub one wouldn't: it's a token, not a password, and it is supposed to expire.- Per-operation, the client gets an access token. Before each pull or push, the client hits
/oauth2/tokenwith the refresh token and a scope string likerepository:team-a/api:pull. ACR issues a narrowly-scoped access token, and the client presents it as a bearer token on the actual API calls.
Two consequences worth internalising:
- Authorization is evaluated at token-issue time, per scope. Revoking a role assignment does not kill tokens already issued; the window is the token lifetime. Plan incident response around that, not around instant revocation.
- The pod pulling your image never had a password. With AKS attached to ACR, the kubelet uses
the cluster's kubelet identity to do exactly this exchange. That's the whole "no
imagePullSecret" story — see Integrations.
The alternatives, in descending order of how much you should want them: managed identity → service
principal → repository-scoped token (a password, but narrowly scoped and expiring) →
admin user (a shared password with full access, readable by anyone with Contributor).
[Image Prompt: 2D minimalistic numbered sequence diagram of the Azure Container Registry authentication exchange showing a managed identity obtaining an Entra token, exchanging it at the registry oauth2 endpoint for a refresh token, requesting a scoped access token per repository operation, and finally pulling a manifest, flat design, clean vector art style, white background]
The data path: what a push actually does
Trace docker push myregistry.azurecr.io/api:v1.2:
- Client asks the registry which layers it already has. For each layer digest, a
HEAD /v2/api/blobs/sha256:…. Existing blobs return200and are skipped entirely — this is layer deduplication, and it's registry-wide, so a layer another team pushed last month counts. - Missing layers are uploaded. A
POSTstarts an upload session, the bytes go up (chunked or monolithic), and aPUTwith the digest finalises it. The registry verifies the SHA-256 — a corrupted upload is rejected, not stored. - The config blob is uploaded the same way.
- The manifest is
PUTat/v2/api/manifests/v1.2. Only now does the image exist as a unit. The registry computes the manifest digest and returns it in theDocker-Content-Digestheader. Capture that value in CI — it is the only unambiguous name for what you just built. - Tag semantics apply. If
v1.2already pointed somewhere and immutability is off, it now points here and the old manifest becomes untagged but still stored and still billed. If immutability is on, step 4 fails with a409-shaped error — which is the correct, desirable behaviour and the reason to turn it on. - Side effects fire. Webhooks POST. Event Grid emits
ImagePushed. Defender for Cloud scanning, if enabled, picks the image up. Replication to other regions begins. Quarantine, if enabled, holds the image unpullable until a scanner clears it.
A pull is the mirror image, and simpler: GET the manifest (by tag or digest), then GET each
missing layer. The layer GET typically returns a 307 redirect to the Microsoft-managed blob
storage endpoint, and the client follows it. That redirect is why naive egress firewalls break ACR
pulls: allowing *.azurecr.io isn't enough, because the bytes come from a storage host. Dedicated
data endpoints (Premium) exist precisely to make that traffic allow-listable as
<registry>.<region>.data.azurecr.io.
Control plane vs. data plane — where the line falls
Azure's split is sharper than AWS's and ACR is the textbook case.
| Operation | Plane | Endpoint | Governed by |
|---|---|---|---|
| Create/delete the registry, change SKU, set network rules, add a replication | Control | management.azure.com |
Owner / Contributor |
| See the registry exists, read its properties | Control | management.azure.com |
Reader |
docker pull, helm pull, resolve a manifest |
Data | <name>.azurecr.io |
AcrPull |
docker push |
Data | <name>.azurecr.io |
AcrPush |
az acr repository delete |
Data | <name>.azurecr.io |
AcrDelete |
az acr repository list |
Data (via CLI convenience) | <name>.azurecr.io |
AcrPull — not Reader |
az acr credential show (read admin password) |
Control | management.azure.com |
Contributor |
The classic mistake is assigning Reader to a service account and expecting it to pull. It
cannot. The mirror mistake is assuming Owner implies data access — it doesn't directly, though
Owner/Contributor can grant themselves AcrPull, or read the admin password if the admin user
is enabled. That escalation path is the strongest argument for keeping the admin user disabled
and enforcing it with Azure Policy.
Three more line-crossings worth naming:
- Network rules are control-plane settings that govern the data plane. Setting
public_network_access_enabled = falsedoesn't touch your role assignments; it makes the data endpoint unreachable from anywhere but a private endpoint. A pipeline with perfect RBAC will still fail, with a DNS or timeout error rather than a403. - Portal browsing uses the data plane. "Repositories" in the portal blade calls
<name>.azurecr.io. On a Premium registry with public access disabled and no private endpoint reaching your browser, the blade shows an error even for an Owner. This looks like a bug and is not one. - ACR Tasks straddles both. Creating a task is control plane; the task itself pushes over the data plane using the task's identity.
[Image Prompt: 2D minimalistic layered diagram showing Azure Resource Manager governing the container registry resource on the control plane and the azurecr.io endpoint governing images on the data plane, with Reader, Contributor, AcrPull, and AcrPush roles placed on the correct side, and the admin-user credential shown as a bypass path, flat design, clean vector art style, white background]
Storage, deduplication, and why "delete" doesn't free space
Layers are stored once per registry per digest, referenced by manifests. This has three consequences that explain most storage surprises:
- Deleting a tag frees nothing. It only removes a pointer. The manifest becomes untagged and keeps every layer alive.
- Deleting a manifest frees only its unreferenced layers. Anything shared with another image stays.
- Untagged manifests accumulate silently. A CI pipeline that pushes
:lateston every merge produces one orphaned manifest per merge, forever. This is the single most common cause of an ACR storage bill nobody can explain. The fix is the retention policy for untagged manifests ⚠️ verify tier availability, or a scheduledaz acr manifest list-metadatasweep.
Encryption at rest is on by default with Microsoft-managed keys; customer-managed keys (Premium) route it through a Key Vault key, which must be configured at registry creation with a user-assigned managed identity — you cannot retrofit CMK onto an existing registry ⚠️ verify current behaviour, as this constraint has moved before. In transit, TLS only.
Geo-replication: one name, many copies, eventual consistency
On Premium, a replication is a regional copy of the registry's content. The mechanics:
- One login server.
myregistry.azurecr.ioresolves via Azure Traffic Manager to the nearest healthy replica. Clients need no configuration and no per-region URL. - Push once, to whichever replica you reach; ACR syncs the rest asynchronously.
- Pulls are local, which is the point — lower latency, and no cross-region egress charges on the pull path.
- Each replica bills as an additional registry at the Premium daily rate ⚠️ verify pricing.
The behaviour that catches people: replication is eventual. A pipeline that pushes in West Europe and immediately deploys in East US can pull a tag that hasn't arrived yet, or — worse — pull the previous manifest that the tag pointed at in that region. Symptom: "the same tag is running two different builds in two regions." Two fixes, both good:
- Deploy by digest. A digest that hasn't replicated yet fails loudly with a
404and your deployment retries; a stale tag fails silently and runs the wrong code. - Gate on replication. Poll the target region (or check the replication status) before the dependent deployment stage runs.
Zone redundancy is a separate, orthogonal Premium setting: it spreads the registry (and each replica you mark) across availability zones within a region, protecting against a zone failure rather than a region failure. It is set per replication and generally must be chosen when the registry or replica is created ⚠️ verify current behaviour.
Scaling model and where the ceilings are
ACR doesn't have "instances" you scale. It has tier-derived limits:
- Throughput — read and write operations per minute, and bandwidth, both step up with the tier.
Exceed them and you get HTTP 429 with a
Retry-Afterheader. Well-behaved clients (containerd, recentdocker) back off; poorly-behaved CI matrices don't. - The realistic throttle scenario is a large cluster scale-out or a node-pool upgrade: hundreds of kubelets pulling the same large image within seconds. Mitigations, in order of effectiveness: smaller images, geo-replication so the load spreads across replicas, and — for AKS specifically — node image caching and staggered rollouts.
- Storage is soft: you're billed past the included allowance rather than blocked, up to a very large per-registry ceiling ⚠️ verify current maximum.
- Scope of the limits is per registry, not per subscription — a genuinely simpler story than most Azure services. The counts that are per-subscription-per-region are the ARM-side ones (number of registries, ACR Tasks concurrency) ⚠️ verify.
Failure modes
| Symptom | Usual cause | What to do |
|---|---|---|
unauthorized: authentication required on pull |
No AcrPull, or the token expired, or Reader was assigned instead |
Check the data-plane role assignment; re-run az acr login |
ImagePullBackOff in AKS with 401 |
Cluster not attached to the registry / kubelet identity missing AcrPull |
az aks update --attach-acr, or assign AcrPull to the kubelet identity |
ImagePullBackOff with a DNS or timeout error |
Private endpoint without the Private DNS zone link, or public access disabled | Fix privatelink.azurecr.io zone linkage; verify from a node with nslookup |
| Pull works, layers hang | Egress firewall allows *.azurecr.io but not the storage redirect |
Enable dedicated data endpoints and allow-list them |
429 Too Many Requests |
Throughput limit for the tier | Raise tier, geo-replicate, shrink images, stagger rollouts |
| Tag runs different code in two regions | Replication lag plus tag-based deployment | Deploy by digest |
| Storage bill growing with no new images | Untagged manifests from repeated tag pushes | Retention policy for untagged manifests |
| Cannot delete the registry with Terraform | Resource lock, or a private endpoint / replication still attached | Check CanNotDelete locks and child resources |
| Portal "Repositories" blade errors for an Owner | Data plane unreachable from the browser (public access off) | Expected; browse from inside the network or use a jump host |
The trade-offs, stated plainly
- Geo-replication buys latency and resilience, and costs a full registry per region plus eventual consistency. Use it when you deploy in multiple regions; don't turn it on "for safety" in a single-region architecture.
- Private endpoints buy a real boundary and cost you DNS complexity and portal usability. Every private-endpoint incident in ACR is a DNS incident.
- Tag immutability buys reproducibility and costs your team the
:latesthabit. Worth it. - Premium buys features, not just headroom. Sizing a registry by storage is the wrong axis; size it by whether you need private networking, replication, CMK, or scope maps.
Next: Getting Started →
← Back to the Azure Container Registry overview · ← Previous: Core Concepts