Background
Sections
IntroductionFoundations1. Resource Hierarchy2. Resource Manager3. Identity and RBAC4. Regions and Availability5. Naming and TaggingVirtual Machines1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetVirtual Network1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetBlob Storage1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure SQL Database1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Kubernetes Service1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Container Registry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetMicrosoft Entra ID1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure RBAC1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Functions1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAPI Management1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure App Configuration1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Machine Learning1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Monitor1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure AI Foundry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and Cheatsheet

5. Deployment

17 min read

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
  }
}

⚠️ azurerm argument 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 what azapi is 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 create defaults 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 AcrPush and nothing else. Not Contributor — 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 CanNotDelete lock on the registry or its resource group makes terraform destroy and terraform 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 plan in CI (nightly, -detailed-exitcode, alert on 2) is the baseline detector.
  • az deployment group what-if does 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 ContainerRegistryLoginEvents tell 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 CanNotDelete or ReadOnly resource 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_destroy should mean this command never runs at all.


Next: Integrations →

← Back to the Azure Container Registry overview · ← Previous: Getting Started