7. Production
The difference between a registry that works and a registry you can run. Five pillars, plus the one discipline — tag hygiene — that is more consequential for ACR than any setting.
[Image Prompt: 2D minimalistic diagram of five production pillars — security, cost, scaling, observability, reliability — arranged around a central Azure Container Registry resource, flat design, clean vector art style, white background]
Security
Turn off the admin user, and prove it stays off. It is a shared password with full push and pull
that appears in logs as the registry's own name rather than as a person, and anyone with
Contributor on the resource can read it with az acr credential show. Disabling it is one
property; keeping it disabled is an Azure Policy assignment at the management group. Do both.
Least privilege on the data plane. The whole model:
| Principal | Role | Why |
|---|---|---|
| AKS kubelet identity / Container App identity / App Service identity | AcrPull |
Runtimes only ever read |
| Build pipeline federated identity | AcrPush |
Push implies pull; that's fine |
| A cleanup job | AcrDelete |
Separate from push, deliberately |
| Platform engineers | Contributor on the registry, via PIM, time-bound |
Because Contributor is a data-plane escalation path via the admin credential |
| Developers | AcrPull on non-prod registries only |
Prod images are not a development resource |
Where the built-in roles are too broad: there is no built-in role that grants push without pull, and none that scope to a repository. If you need repository granularity, Premium scope maps and tokens are the only mechanism — and they issue a password, so treat each token as a secret with an owner, an expiry, and a rotation date. The honest alternative, and usually the better one, is more registries.
Keyless everywhere. Managed identity for every runtime, workload identity federation for every pipeline. If a connection string or a registry password appears anywhere in a config file, an app setting, or a Kubernetes secret, something in the design was skipped — see Integrations.
Network isolation. Premium + private endpoint + privatelink.azurecr.io, with
public_network_access_enabled = false. Two things people get wrong:
- The portal stops working for the Repositories blade, because it browses over the data plane from your browser. This is correct behaviour, not a misconfiguration. Plan for a jump host or accept CLI-from-inside-the-network as the browse path.
- The layer-blob redirect goes to Microsoft-managed storage hostnames, so a firewall allowing
only
*.azurecr.iobreaks pulls halfway. Enable dedicated data endpoints and allow-list<registry>.<region>.data.azurecr.iofor every replica region.
Encryption. At rest with Microsoft-managed keys by default; customer-managed keys (Premium) route through a Key Vault key with a user-assigned identity and generally must be configured at creation ⚠️ verify current behaviour. In transit, TLS only — there is no plaintext option.
Supply chain. Three controls, in increasing order of maturity:
- Tag immutability — so an audited tag cannot be repointed. Cheap, high value, turn it on.
- Scan on push with Defender for Cloud, and act on the running findings before the repository findings.
- Sign and verify. Sign with Notation (Notary v2) using a key in Key Vault, and verify at Kubernetes admission with Ratify or an equivalent. Note that ACR's older content trust (Notary v1 / Docker Content Trust) is on a retirement path ⚠️ verify the current date — do not start new work on it.
Exfiltration. The export policy can block az acr import/export out of a locked-down
Premium registry. It requires public network access to be disabled, and it will also block your own
promotion pipeline — which is the point. Decide consciously.
Cost
What you actually pay for:
- A fixed daily rate per tier, charged whether or not a single pull happens. This is the line people forget: an idle Premium registry has a non-trivial monthly cost.
- Storage above the included allowance, per GiB per day (Basic ~10 GiB, Standard ~100 GiB, Premium ~500 GiB included ⚠️ verify current figures).
- Each geo-replication region, billed at approximately a full additional Premium daily rate ⚠️ verify.
- Egress — bandwidth leaving the region. Pulls from within the same region are the cheap case; a cluster in East US pulling from a West Europe registry pays both latency and money.
- ACR Tasks compute, per CPU-second beyond a free monthly allowance ⚠️ verify.
The biggest cost trap, by a distance: untagged manifests. Every CI push to a moving tag
(:latest, :main, :dev) orphans the previous manifest, which keeps its unshared layers alive
forever. A pipeline running fifty times a day produces eighteen thousand orphaned manifests a year.
The fix is a retention policy for untagged manifests ⚠️ verify tier availability:
az acr config retention update -r acrplatformprod --status enabled --days 14 --type UntaggedManifests
The second trap: replicas nobody removed. A replica added for a region you no longer deploy to bills every day and is invisible in the resource list unless you look at the registry's Replications blade.
The third: cross-region pulls. If a workload pulls from another region's registry on every pod start, you're paying egress on a hot path. Either geo-replicate or move the registry.
Concrete optimisations, in order of payoff:
- Enable untagged-manifest retention. Usually the single biggest win, and it's one command.
- Tag by immutable commit SHA rather than by a moving tag, so orphaning stops happening at all.
- Shrink images — multi-stage builds, distroless or Mariner bases. Smaller layers mean less storage, less egress, faster pulls, and less throttling. This is the optimisation that pays in four currencies.
- Audit replicas quarterly.
- Right-size the tier — but size by feature need, not by storage. If you don't need private endpoints, replication, CMK, or scope maps, Standard is fine and much cheaper.
What keeps billing when idle: the tier's daily rate, all stored bytes, and every replica. Only egress and Tasks are usage-driven. A registry is close to a fixed cost.
Scaling and limits
ACR has no instance count. It has tier-derived ceilings, and — pleasantly — almost all of them are scoped per registry, not per subscription:
| Limit | Scope | Notes |
|---|---|---|
| Storage (included, then billed) | Per registry | Basic ~10 GiB / Standard ~100 GiB / Premium ~500 GiB ⚠️ verify; hard ceiling far higher |
| Read operations per minute | Per registry | Steps up with tier ⚠️ verify current figures |
| Write operations per minute | Per registry | Steps up with tier ⚠️ verify |
| Download / upload bandwidth | Per registry | Steps up with tier ⚠️ verify |
| Webhooks | Per registry | ~2 / ~10 / ~500 by tier ⚠️ verify |
| Geo-replications | Per registry (Premium) | Practically limited by regions and budget |
| Repositories and tags | Per registry | Very large; not a practical constraint ⚠️ verify |
| Registries per subscription | Per subscription-per-region | A quota you can raise via support ⚠️ verify |
| ACR Tasks concurrency | Per registry, by tier | Higher on Premium ⚠️ verify |
Throttling is HTTP 429 with Retry-After. The realistic trigger is not steady-state traffic —
it's a burst: an AKS node pool upgrade, a large scale-out, or a CI matrix building twenty variants
at once, all pulling the same large image within seconds. Mitigations, most to least effective:
- Smaller images. A 200 MB image throttles at ten times the concurrency of a 2 GB one.
- Geo-replication, which spreads the load across regional replicas.
- Staggered rollouts —
maxSurgeon node pool upgrades, deploymentmaxUnavailable. - Raise the tier. Premium's headroom is real, but it's the answer that costs money to buy time.
Well-behaved clients (containerd, current docker, kubelet) honour Retry-After and back off.
Hand-rolled scripts in a for loop do not, and are usually the actual cause.
Observability
Diagnostic settings are not on by default. Until you create one, you have platform metrics and nothing else — no record of who pulled what. Route these to a Log Analytics workspace:
| Category | What it answers |
|---|---|
ContainerRegistryLoginEvents |
Who authenticated, from where, and whether it succeeded. Your audit trail |
ContainerRegistryRepositoryEvents |
Push, pull, delete, per repository and tag. Your usage and forensics trail |
AllMetrics |
Storage used, pull/push counts, agent-pool CPU for Tasks |
az monitor diagnostic-settings create \
-n diag-acr --resource $(az acr show -n acrplatformprod -g rg-platform --query id -o tsv) \
--workspace <log-analytics-workspace-id> \
--logs '[{"category":"ContainerRegistryLoginEvents","enabled":true},
{"category":"ContainerRegistryRepositoryEvents","enabled":true}]' \
--metrics '[{"category":"AllMetrics","enabled":true}]'
Metrics worth alerting on:
StorageUsedtrending up with no release cadence to explain it → your retention policy is off or ineffective.- Failed pull count /
401s in login events → a role assignment was removed, a token expired, or something is still using the admin user. 429responses → throttling; correlate with cluster scale events.
The KQL that answers the most common ACR question — who pushed this image, and when?
ContainerRegistryRepositoryEvents
| where TimeGenerated > ago(30d)
| where Repository == "api" and OperationName == "Push"
| project TimeGenerated, Repository, Tag, Digest, Identity, CallerIpAddress, OperationName
| order by TimeGenerated desc
And the second most common — is anyone still pulling this repository, or can I delete it?
ContainerRegistryRepositoryEvents
| where TimeGenerated > ago(90d) and OperationName == "Pull"
| summarize Pulls = count(), LastPull = max(TimeGenerated) by Repository
| order by LastPull asc
The activity log covers control-plane changes — SKU changes, network rule edits, someone
enabling the admin user. Alert on Microsoft.ContainerRegistry/registries/write in prod; the
registry's shape should change rarely enough that every change is worth a notification.
Reliability
What the redundancy settings actually protect against:
| Setting | Protects against | Doesn't protect against |
|---|---|---|
| Zone redundancy (Premium) | Loss of one availability zone in a region | Regional outage; accidental deletion |
| Geo-replication (Premium) | Regional outage; also fixes cross-region pull latency and egress | Accidental deletion — deletes replicate |
| Soft delete policy ⚠️ verify status | Accidental artifact deletion, within the retention window | Registry deletion; a determined purge |
Resource lock (CanNotDelete) |
Accidental registry deletion | Content deletion inside the registry |
Note the gap that table exposes: nothing in the list is a backup. If you need a true independent
copy, az acr import into a registry in a different subscription, on a schedule. For most teams
the honest position is that images are rebuildable from source and the registry is a cache — but
say that out loud and check that it's true, because "rebuildable" assumes the base image, the
dependencies, and the build toolchain are all still available.
The failure drill worth running once: disable public network access on a staging registry with a private endpoint, then roll a deployment. You'll discover, in a controlled way, whether your DNS is right, whether your firewall allows the data endpoints, and whether anything still authenticates with the admin user. Every one of those is better found on a Tuesday.
Impact shape, restated because it drives your alerting: an ACR outage does not stop running
pods. It stops new ones — deployments, scale-outs, node repairs, restarts, and anything with
imagePullPolicy: Always. Impact is invisible for a while and then total. Alert on pull failures,
not just on registry availability.
Availability SLA is quoted per tier ⚠️ verify current percentages, and it covers the registry endpoint. Geo-replication and zone redundancy improve your effective availability without changing the printed figure.
The discipline that matters more than any setting
Tag hygiene. Everything hard about running a registry traces back to tags being treated as if they were immutable when they aren't.
- Turn on tag immutability.
v1.2.0should mean one thing forever. - Tag with the commit SHA in CI, and apply semantic tags only at release.
- Deploy by digest. Every manifest, every Helm value, every
az containerapp updateshould carry@sha256:…. Tags are for humans reading the portal. - Never deploy
:latest. It defeats rollback, defeats geo-replication consistency, defeats scanning attribution, and defeats your ability to answer "what's running." - Retain untagged manifests for a short window, then delete them.
A registry with immutable tags, digest-based deployment, and a retention policy is a registry that mostly runs itself. One without them generates a steady trickle of incidents that all look different and all have the same cause.
Next: Interview Questions →
← Back to the Azure Container Registry overview · ← Previous: Integrations