5. Deployment
Getting Started proved the registry exists. This page makes it repeatable, reviewable, and reversible: a parameterised Terraform module, remote state, an Ansible playbook for day-2 work, the Bicep equivalent, an OIDC pipeline, an environment strategy, and a rollback story that survives 2 a.m.
There's a wrinkle specific to registries that shapes everything below. A registry has two lifecycles. The resource is infrastructure — Terraform's job, changed a few times a year. The images inside it are artifacts — the pipeline's job, changed several times a day. Never let Terraform manage image content, and never let the image pipeline manage the registry's SKU or network rules. Keeping that line clean is most of the discipline.
Tool order
| Rank | Tool | Role here |
|---|---|---|
| 1. Primary | Terraform (azurerm, plus azapi for anything the provider hasn't caught up on) |
The registry, replications, private endpoints, policies, diagnostic settings, and role assignments |
| 2. Secondary | Ansible (azure.azcollection) |
Day-2 operations — retention sweeps, importing images between registries, rotating token passwords, promoting a digest |
| 3. Third | Bicep / ARM | The Azure-native equivalent, and the right choice for brand-new ACR features that azurerm hasn't modelled yet |
All three genuinely apply to ACR, so all three are written below.
1. Terraform — the module
variables.tf
variable "name_prefix" {
type = string
description = "Short alphanumeric prefix. Registry names allow no hyphens."
validation {
condition = can(regex("^[a-z0-9]{2,20}$", var.name_prefix))
error_message = "name_prefix must be lowercase alphanumeric only — ACR names reject hyphens."
}
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "stg", "prod"], var.environment)
error_message = "environment must be dev, stg, or prod."
}
}
variable "location" {
type = string
default = "uksouth"
}
variable "sku" {
type = string
default = "Premium"
description = "Basic | Standard | Premium. Premium is required for private endpoints, geo-replication, CMK, scope maps, and zone redundancy."
}
variable "replica_locations" {
type = list(string)
default = []
description = "Additional regions to geo-replicate to. Premium only. Each replica bills as an additional registry."
}
variable "private_endpoint_subnet_id" {
type = string
default = null
}
variable "log_analytics_workspace_id" {
type = string
default = null
}
main.tf
terraform {
required_version = ">= 1.6"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azapi = { source = "Azure/azapi", version = "~> 2.0" }
}
}
provider "azurerm" {
# The features block is mandatory even when empty. For ACR specifically there is no
# ACR-shaped toggle in it today — but a registry deployment almost always drags in Key Vault
# (for CMK), and Key Vault's purge behaviour is controlled here. Set it deliberately:
# leaving purge_soft_delete_on_destroy at its default means a destroyed vault keeps its name
# reserved and blocks recreation — the classic Azure teardown surprise.
features {
key_vault {
purge_soft_delete_on_destroy = false # never true in prod
recover_soft_deleted_key_vaults = true
}
}
}
locals {
registry_name = "acr${var.name_prefix}${var.environment}" # no hyphens allowed
is_prod = var.environment == "prod"
tags = {
Environment = var.environment
ManagedBy = "terraform"
Component = "container-registry"
}
}
resource "azurerm_resource_group" "this" {
name = "rg-${var.name_prefix}-acr-${var.environment}"
location = var.location
tags = local.tags
}
resource "azurerm_user_assigned_identity" "acr" {
name = "id-${var.name_prefix}-acr-${var.environment}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = local.tags
}
resource "azurerm_container_registry" "this" {
name = local.registry_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
sku = var.sku
# --- the settings that matter more than the SKU ---
admin_enabled = false # shared password; never in prod
public_network_access_enabled = !local.is_prod # prod pulls come through the private endpoint
zone_redundancy_enabled = var.sku == "Premium" && local.is_prod
anonymous_pull_enabled = false
data_endpoint_enabled = var.sku == "Premium" # allow-listable <registry>.<region>.data.azurecr.io
export_policy_enabled = !local.is_prod # blocking export limits exfiltration; requires public access off
network_rule_bypass_option = "AzureServices"
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.acr.id]
}
# Premium only. Each replica is an additional daily charge.
dynamic "georeplications" {
for_each = var.sku == "Premium" ? var.replica_locations : []
content {
location = georeplications.value
zone_redundancy_enabled = local.is_prod
tags = local.tags
}
}
# Untagged manifests from CI accumulate forever without this.
retention_policy_in_days = 14
trust_policy_enabled = false # content trust / Notary v1 is on a retirement path — prefer Notation
tags = local.tags
lifecycle {
# A registry name change forces replacement, which destroys every image in it.
prevent_destroy = true
}
}
⚠️
azurermargument names for the retention and trust policies have changed across major provider versions (they were nested blocks before v4). Check the provider docs for the version you're pinning to rather than copying blindly. Anything the provider hasn't modelled yet — new preview policies, for instance — is whatazapiis for:
# azapi escape hatch: manage a registry property azurerm hasn't caught up on yet
resource "azapi_update_resource" "soft_delete" {
type = "Microsoft.ContainerRegistry/registries@2023-11-01-preview" # ⚠️ verify current API version
resource_id = azurerm_container_registry.this.id
body = {
properties = {
policies = {
softDeletePolicy = {
status = "enabled"
retentionDays = 7
}
}
}
}
}
Networking, RBAC, and diagnostics
resource "azurerm_private_endpoint" "acr" {
count = local.is_prod && var.private_endpoint_subnet_id != null ? 1 : 0
name = "pe-${local.registry_name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
subnet_id = var.private_endpoint_subnet_id
private_service_connection {
name = "psc-${local.registry_name}"
private_connection_resource_id = azurerm_container_registry.this.id
subresource_names = ["registry"] # the only sub-resource ACR exposes
is_manual_connection = false
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.acr[0].id]
}
}
resource "azurerm_private_dns_zone" "acr" {
count = local.is_prod ? 1 : 0
name = "privatelink.azurecr.io" # the zone name is fixed; getting it wrong is the #1 ACR networking bug
resource_group_name = azurerm_resource_group.this.name
}
# Data-plane access for the thing that runs the images. AcrPull, not Reader.
resource "azurerm_role_assignment" "aks_pull" {
scope = azurerm_container_registry.this.id
role_definition_name = "AcrPull"
principal_id = var.aks_kubelet_object_id
}
resource "azurerm_monitor_diagnostic_setting" "acr" {
count = var.log_analytics_workspace_id != null ? 1 : 0
name = "diag-${local.registry_name}"
target_resource_id = azurerm_container_registry.this.id
log_analytics_workspace_id = var.log_analytics_workspace_id
enabled_log { category = "ContainerRegistryLoginEvents" }
enabled_log { category = "ContainerRegistryRepositoryEvents" }
metric { category = "AllMetrics" }
}
outputs.tf
output "login_server" { value = azurerm_container_registry.this.login_server }
output "registry_id" { value = azurerm_container_registry.this.id }
output "identity_id" { value = azurerm_user_assigned_identity.acr.id }
The loop
terraform init -backend-config=backends/prod.hcl
terraform plan -var-file=envs/prod.tfvars -out=tfplan
terraform show -no-color tfplan | less # read it. always.
terraform apply tfplan
2. Remote state and locking
Local state on a team is a footgun: two people applying concurrently produce a registry that matches neither plan.
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstateplatform"
container_name = "tfstate"
key = "acr/prod.tfstate"
use_azuread_auth = true # Entra auth to the state blob; no storage keys
}
}
The azurerm backend locks with a native blob lease — no DynamoDB-equivalent table to create,
which is one of the genuinely nicer parts of Terraform on Azure. Protect the state storage account
like a secret store: it contains resource IDs, and for some resource types, sensitive values.
Workspaces vs. directory-per-environment. Workspaces share one backend key prefix and one
configuration, which is tempting and wrong here, because prod's registry differs from dev's in
kind (Premium, private endpoint, no public access) rather than in size. Use separate state
files and separate .tfvars — the layout above — so a mistake in dev cannot plan a change in
prod.
3. Ansible — day-2 operations
Ansible's role here is not to create the registry (Terraform owns that) but to do the imperative, ordered work that Terraform is bad at: promoting a specific digest between registries, sweeping untagged manifests, and rotating token passwords.
- name: ACR day-2 operations
hosts: localhost
connection: local
gather_facts: false
vars:
name_prefix: platform
environment: prod
registry_name: "acr{{ name_prefix }}{{ environment }}"
resource_group: "rg-{{ name_prefix }}-acr-{{ environment }}"
tasks:
# Idempotent: a second run reports "ok", not "changed".
- name: Ensure the registry exists with the expected shape
azure.azcollection.azure_rm_containerregistry:
name: "{{ registry_name }}"
resource_group: "{{ resource_group }}"
location: uksouth
sku: Premium
admin_user_enabled: false
state: present
register: acr_state
- name: Show that a repeat run changes nothing
ansible.builtin.debug:
msg: "changed={{ acr_state.changed }} — expect false on the second run"
# Promotion between environments: import BY DIGEST, never by tag.
# az acr import is a server-side copy — no bytes cross your network, no docker daemon needed.
- name: Promote a verified build from staging into prod by digest
ansible.builtin.command:
argv:
- az
- acr
- import
- --name
- "{{ registry_name }}"
- --source
- "acr{{ name_prefix }}stg.azurecr.io/api@{{ image_digest }}"
- --image
- "api:{{ release_tag }}"
changed_when: true
- name: Sweep untagged manifests older than the retention window
ansible.builtin.shell: >
az acr manifest list-metadata --registry {{ registry_name }} --name api
--query "[?tags==null].digest" -o tsv
| xargs -r -I{} az acr manifest delete --registry {{ registry_name }} --name api@{} --yes
changed_when: true
Authentication: a managed identity on the runner (ansible_azure_auth_source: msi) or a
service principal from the environment. Never a credentials file in the repo.
Where Ansible is the wrong tool: anything about the registry's shape. If a playbook and a
Terraform module both claim to own sku or public_network_access_enabled, you have built a drift
generator.
4. Bicep / ARM
Bicep equivalent, plus what-if and the deployment-mode warning
@minLength(5)
@maxLength(50)
param registryName string
param location string = resourceGroup().location
@allowed(['Basic', 'Standard', 'Premium'])
param sku string = 'Premium'
param replicaLocations array = []
param isProd bool = false
resource acr 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = {
name: registryName
location: location
sku: { name: sku }
identity: { type: 'SystemAssigned' }
properties: {
adminUserEnabled: false
publicNetworkAccess: isProd ? 'Disabled' : 'Enabled'
zoneRedundancy: (sku == 'Premium' && isProd) ? 'Enabled' : 'Disabled'
dataEndpointEnabled: sku == 'Premium'
anonymousPullEnabled: false
policies: {
retentionPolicy: { status: 'enabled', days: 14 }
exportPolicy: { status: isProd ? 'disabled' : 'enabled' }
}
}
}
resource replicas 'Microsoft.ContainerRegistry/registries/replications@2023-11-01-preview' = [
for loc in replicaLocations: {
parent: acr
name: loc
location: loc
properties: { zoneRedundancy: isProd ? 'Enabled' : 'Disabled' }
}
]
output loginServer string = acr.properties.loginServer
# Preview before deploying — Bicep's answer to terraform plan
az deployment group what-if \
-g rg-platform-acr-prod \
-f acr.bicep \
-p registryName=acrplatformprod sku=Premium isProd=true
az deployment group create -g rg-platform-acr-prod -f acr.bicep -p @prod.params.json
⚠️ Deployment modes.
az deployment group createdefaults to incremental: resources in the group that aren't in the template are left alone. Complete mode (--mode Complete) deletes every resource in the resource group that the template doesn't declare. Running a registry-only template in complete mode against a shared resource group will delete the private endpoint, the DNS zone links, the diagnostic settings, and anything else that lives there. For ACR the registry itself usually survives (it's in the template) — but a deleted private endpoint means every prod pull fails within seconds. Never use complete mode against a resource group you don't fully own in one template.
Where Bicep beats Terraform for ACR: new ACR features land in the ARM API on day one, and
azurerm follows later. If you need a policy or property the provider hasn't modelled, Bicep — or
Terraform's azapi provider, shown above — is the honest path.
5. CI/CD — OIDC, never a secret
Two pipelines, deliberately separate: infrastructure (Terraform, rare) and images (build and push, constant).
name: acr-infrastructure
on:
pull_request:
paths: ['infra/acr/**']
push:
branches: [main]
paths: ['infra/acr/**']
permissions:
id-token: write # required for OIDC — this is the line that replaces a client secret
contents: read
jobs:
plan:
runs-on: ubuntu-latest
environment: prod # approval gate lives on the environment
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }} # federated credential — no secret
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: hashicorp/setup-terraform@v3
- run: terraform init -backend-config=backends/prod.hcl
working-directory: infra/acr
- run: terraform plan -var-file=envs/prod.tfvars -out=tfplan
working-directory: infra/acr
- if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve tfplan
working-directory: infra/acr
The workload identity federation setup, once: register an Entra application, add a federated
credential whose issuer is https://token.actions.githubusercontent.com and whose subject matches
repo:<org>/<repo>:environment:prod, then assign that app Contributor on the target resource
group. There is now no client secret, no publish profile, and nothing to rotate. Anything older
than this pattern is a finding waiting to happen.
The image pipeline is smaller and matters more:
name: build-and-push
permissions:
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
# az acr login does the Entra token exchange; the federated identity needs AcrPush only.
- run: az acr login -n acrplatformstg
- name: Build and push, tagged by immutable commit SHA
run: |
docker build -t acrplatformstg.azurecr.io/api:${{ github.sha }} .
docker push acrplatformstg.azurecr.io/api:${{ github.sha }}
- name: Capture the digest — the only unambiguous identifier for this build
id: digest
run: |
D=$(az acr repository show -n acrplatformstg \
--image api:${{ github.sha }} --query digest -o tsv)
echo "digest=$D" >> "$GITHUB_OUTPUT"
Two rules that make everything downstream easier:
- The pipeline identity gets
AcrPushand nothing else. NotContributor— that can read the admin password. - Every deployment references the digest, and the digest is the artifact that flows through the promotion chain. Tags are for humans.
[Image Prompt: 2D minimalistic pipeline diagram showing a git commit flowing through terraform plan, approval, and apply to provision an Azure Container Registry, alongside a parallel image path where a build pushes to a staging registry and the resulting digest is promoted by server-side import into a production registry, flat design, clean vector art style, white background]
6. Environments — and the promotion question ACR forces
Dev / staging / prod differ in .tfvars, in state file, in resource group, and — for anything
regulated — in subscription, because the subscription is Azure's natural blast-radius, quota,
and policy boundary. Enforce the difference with Azure Policy at a management group rather than
by convention: deny adminUserEnabled: true, deny Basic/Standard SKUs in the prod management
group, deny publicNetworkAccess: Enabled, require diagnostic settings.
Then the ACR-specific question: one registry or several?
| Shape | Case for | Case against |
|---|---|---|
One registry, tags per environment (api:v1.2, promoted by retagging) |
Cheapest, one push, no copy step | No isolation — a dev principal with AcrPull reads prod images. No network separation. A dev pipeline can overwrite a prod tag |
One registry per environment, promote by az acr import |
Clean blast radius, prod can be Premium + private while dev stays Standard, dev credentials can't touch prod | An extra promotion step; slightly more Terraform |
| One registry per team/product | Real RBAC isolation without Premium scope maps | Registry sprawl; duplicated base layers across registries defeats dedup |
The recommendation: one registry per environment, promoted by digest with az acr import.
import is a server-side copy — no bytes traverse your network, no Docker daemon, and the
digest is preserved, so the thing you tested is provably the thing you shipped:
az acr import \
--name acrplatformprod \
--source acrplatformstg.azurecr.io/api@sha256:9f86d0... \
--image api:v1.2.0
Note that import requires the export policy to be enabled on the source registry — the
setting that exists to prevent exfiltration also prevents your own promotion path. Decide which you
want, and write it down.
7. Rollback and blast radius
What "undo" means here depends on what changed.
If a bad image shipped: the rollback is a deployment change, not a registry change — repoint
the workload at the previous digest. This is why digests matter: kubectl set image deploy/api api=acr...@sha256:<previous> is exact, instant, and cannot resolve to the wrong build.
Deleting the bad image from the registry is cleanup, not rollback, and doing it first makes the
incident worse by breaking any node that still needs to pull it.
If a bad registry config shipped: re-apply the previous commit. Most ACR properties are in-place updates — SKU changes, policy toggles, network rules, adding or removing replicas — and take seconds.
Operations that are destructive or force replacement:
| Change | Effect |
|---|---|
name |
Forces replacement — destroys the registry and every image in it. The prevent_destroy lifecycle block above exists for exactly this |
resource_group_name / location |
Forces replacement. Same consequence |
| Enabling customer-managed keys | Must generally be set at creation ⚠️ verify current behaviour; retrofitting may require a new registry |
Removing a georeplications block |
Deletes that replica. Non-destructive to content (the home region holds it) but every client in that region now pulls cross-region — a latency and egress change, not an outage |
| Downgrading Premium → Standard | Silently invalidates private endpoints, replications, and scope maps. Terraform may plan it cleanly and break prod |
Enabling public_network_access_enabled = false without a working private endpoint |
Immediate, total outage of the pull path. The most likely way to break production from a one-line diff |
The two Azure-specific traps to name:
- Soft delete. With the soft-delete policy enabled, deleted artifacts — and the registry's own name — remain reserved for the retention window ⚠️ verify current preview/GA status. A destroy followed by a recreate with the same name can fail with a name-unavailable error that reads like a global-uniqueness collision.
- Resource locks. A
CanNotDeletelock on the registry or its resource group makesterraform destroyandterraform apply(for replacement-forcing changes) fail with what looks like an RBAC error. Locks are the right control for a prod registry; just know their error signature.
Blast radius, honestly stated: a registry outage does not stop running pods — images are already on the nodes. It stops new pods: deployments, scale-outs, node repairs, and restarts. That's why the impact is often invisible for twenty minutes and then total.
8. Drift
Registries drift in a specific way: someone enables the admin user "just to test something", or flips public network access on to unblock a pipeline, and never reverts it.
- Scheduled
terraform planin CI (nightly,-detailed-exitcode, alert on2) is the baseline detector. az deployment group what-ifdoes the same for Bicep-managed registries.- Azure Policy compliance state is the better detector for the specific properties you care about, because it reports continuously and catches resources Terraform doesn't manage at all. Policies worth assigning: deny admin user, deny public network access, audit anonymous pull, require diagnostic settings.
- The activity log and
ContainerRegistryLoginEventstell you who pushed and who pulled, which the plan output can't.
The remediation is always the same: re-apply from the module and, if the drift was deliberate, fix the module so the next person doesn't need to click.
Teardown
terraform destroy -var-file=envs/dev.tfvars
What destroy will not remove: soft-deleted artifacts and the reserved registry name during the retention window; anything behind a
CanNotDeleteorReadOnlyresource lock; a Key Vault holding a customer-managed key if soft delete and purge protection are on (and with purge protection, that vault genuinely cannot be purged early — this is by design); role assignments created outside the state file, which linger as orphaned assignments against a deleted scope; diagnostic settings created by policy rather than by the module; and the private DNS zone if it lives in a shared networking state. On a real prod registry,prevent_destroyshould mean this command never runs at all.
Next: Integrations →
← Back to the Azure Container Registry overview · ← Previous: Getting Started