IaC Azure

Terraform Module: Azure Container Registry — Private, Hardened Image Hosting in One Module

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Container Registry: SKU-aware geo-replication, RBAC auth, retention policies, and private endpoint hardening with validated inputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "container_registry" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-container-registry?ref=v1.0.0"

  name                = "..."  # Globally unique ACR name (5-50 alphanumerics, no hyphen…
  resource_group_name = "..."  # Resource group to create the registry in.
  location            = "..."  # Azure region for the primary registry.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Container Registry (ACR) is a managed, private OCI registry for storing and distributing container images, Helm charts, and other OCI artifacts. It is the backbone of almost every container workload on Azure — AKS pulls from it, App Service and Container Apps pull from it, and your CI pipeline pushes to it. Out of the box azurerm_container_registry exposes dozens of knobs: SKU tiers (Basic/Standard/Premium), admin user toggle, geo-replication, retention and trust policies, network rules, private endpoints, and customer-managed-key encryption. Getting them wrong is how teams end up with a public, admin-credential-enabled registry that anyone with the password can push poisoned images into.

This module wraps azurerm_container_registry into a single, opinionated, var-driven unit so every registry across your estate is created the same hardened way: admin user disabled by default, retention policy on, and Premium-only features (geo-replication, private endpoints, zone redundancy) guarded by validation so you can’t accidentally request them on a Basic SKU and get a confusing apply-time error. It bakes in the two sub-resources you almost always need in production — geo-replicated regions and a private endpoint — and emits the outputs (login_server, id, identity principal) that downstream AKS, role assignment, and pipeline configs consume.

When to use it

Module structure

terraform-module-azure-container-registry/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_container_registry + replication + private endpoint
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, login_server, identity, private endpoint IP

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  # Geo-replication, zone redundancy, private endpoints and CMK are Premium-only.
  is_premium = var.sku == "Premium"
}

resource "azurerm_container_registry" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location
  sku                 = var.sku

  # Security baseline: admin user off, RBAC/token auth instead.
  admin_enabled                 = var.admin_enabled
  public_network_access_enabled = var.public_network_access_enabled
  anonymous_pull_enabled        = var.anonymous_pull_enabled

  # Premium-only hardening. Guarded so non-Premium SKUs send null (provider default).
  zone_redundancy_enabled       = local.is_premium ? var.zone_redundancy_enabled : null
  export_policy_enabled         = local.is_premium ? var.export_policy_enabled : true
  data_endpoint_enabled         = local.is_premium ? var.data_endpoint_enabled : null
  network_rule_bypass_option    = local.is_premium ? var.network_rule_bypass_option : "AzureServices"

  # Reclaim space by purging untagged manifests after N days (Standard/Premium).
  retention_policy_in_days = var.retention_in_days
  trust_policy_enabled     = local.is_premium ? var.trust_policy_enabled : false

  # System-assigned identity lets the registry authenticate to CMK / tasks.
  dynamic "identity" {
    for_each = var.identity_type == null ? [] : [1]
    content {
      type         = var.identity_type
      identity_ids = var.identity_type == "UserAssigned" || var.identity_type == "SystemAssigned, UserAssigned" ? var.identity_ids : null
    }
  }

  # IP allow-list (Premium only). Ignored unless public access is enabled.
  dynamic "network_rule_set" {
    for_each = local.is_premium && length(var.allowed_ip_ranges) > 0 ? [1] : []
    content {
      default_action = "Deny"
      dynamic "ip_rule" {
        for_each = var.allowed_ip_ranges
        content {
          action   = "Allow"
          ip_range = ip_rule.value
        }
      }
    }
  }

  # Geo-replicate to additional regions for low-latency pulls (Premium only).
  dynamic "georeplications" {
    for_each = local.is_premium ? var.georeplications : []
    content {
      location                  = georeplications.value.location
      zone_redundancy_enabled   = try(georeplications.value.zone_redundancy_enabled, false)
      regional_endpoint_enabled = try(georeplications.value.regional_endpoint_enabled, true)
      tags                      = var.tags
    }
  }

  tags = var.tags
}

# Optional private endpoint to keep registry traffic off the public internet.
resource "azurerm_private_endpoint" "this" {
  count = var.private_endpoint != null ? 1 : 0

  name                = coalesce(var.private_endpoint.name, "${var.name}-pe")
  resource_group_name = var.resource_group_name
  location            = var.location
  subnet_id           = var.private_endpoint.subnet_id

  private_service_connection {
    name                           = "${var.name}-psc"
    private_connection_resource_id = azurerm_container_registry.this.id
    subresource_names              = ["registry"]
    is_manual_connection           = false
  }

  dynamic "private_dns_zone_group" {
    for_each = var.private_endpoint.private_dns_zone_ids != null ? [1] : []
    content {
      name                 = "acr-dns-zone-group"
      private_dns_zone_ids = var.private_endpoint.private_dns_zone_ids
    }
  }

  tags = var.tags
}

variables.tf

variable "name" {
  description = "Globally unique ACR name (alphanumeric, 5-50 chars, no hyphens)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9]{5,50}$", var.name))
    error_message = "ACR name must be 5-50 alphanumeric characters with no hyphens or symbols."
  }
}

variable "resource_group_name" {
  description = "Name of the resource group to create the registry in."
  type        = string
}

variable "location" {
  description = "Azure region for the primary registry (e.g. centralindia)."
  type        = string
}

variable "sku" {
  description = "Registry SKU. Premium unlocks geo-replication, zone redundancy, private endpoints and CMK."
  type        = string
  default     = "Standard"

  validation {
    condition     = contains(["Basic", "Standard", "Premium"], var.sku)
    error_message = "sku must be one of: Basic, Standard, Premium."
  }
}

variable "admin_enabled" {
  description = "Enable the admin user (username/password). Keep false; use RBAC or tokens."
  type        = bool
  default     = false
}

variable "public_network_access_enabled" {
  description = "Allow access over the public endpoint. Set false when using a private endpoint."
  type        = bool
  default     = true
}

variable "anonymous_pull_enabled" {
  description = "Allow unauthenticated (anonymous) pulls. Almost always false."
  type        = bool
  default     = false
}

variable "zone_redundancy_enabled" {
  description = "Spread the primary registry across availability zones (Premium only)."
  type        = bool
  default     = true
}

variable "export_policy_enabled" {
  description = "Allow artifacts to be exported/imported out of the registry (Premium only)."
  type        = bool
  default     = true
}

variable "data_endpoint_enabled" {
  description = "Enable dedicated data endpoints for firewall-scoped pulls (Premium only)."
  type        = bool
  default     = false
}

variable "network_rule_bypass_option" {
  description = "Whether trusted Azure services bypass network rules (Premium only)."
  type        = string
  default     = "AzureServices"

  validation {
    condition     = contains(["AzureServices", "None"], var.network_rule_bypass_option)
    error_message = "network_rule_bypass_option must be AzureServices or None."
  }
}

variable "retention_in_days" {
  description = "Days to keep untagged manifests before purge. 0 disables retention (Standard/Premium)."
  type        = number
  default     = 7

  validation {
    condition     = var.retention_in_days >= 0 && var.retention_in_days <= 365
    error_message = "retention_in_days must be between 0 and 365."
  }
}

variable "trust_policy_enabled" {
  description = "Enable content trust (image signing) on the registry (Premium only)."
  type        = bool
  default     = false
}

variable "identity_type" {
  description = "Managed identity type: SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null."
  type        = string
  default     = "SystemAssigned"

  validation {
    condition = var.identity_type == null || contains(
      ["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"],
      var.identity_type
    )
    error_message = "identity_type must be SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null."
  }
}

variable "identity_ids" {
  description = "User-assigned identity resource IDs (required when identity_type includes UserAssigned)."
  type        = list(string)
  default     = []
}

variable "allowed_ip_ranges" {
  description = "CIDR ranges allowed through the registry firewall (Premium only)."
  type        = list(string)
  default     = []
}

variable "georeplications" {
  description = "Additional regions to geo-replicate to (Premium only)."
  type = list(object({
    location                  = string
    zone_redundancy_enabled   = optional(bool, false)
    regional_endpoint_enabled = optional(bool, true)
  }))
  default = []
}

variable "private_endpoint" {
  description = "Optional private endpoint config. Set subnet_id and DNS zone IDs to enable."
  type = object({
    name                 = optional(string)
    subnet_id            = string
    private_dns_zone_ids = optional(list(string))
  })
  default = null
}

variable "tags" {
  description = "Tags applied to the registry and its sub-resources."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the container registry."
  value       = azurerm_container_registry.this.id
}

output "name" {
  description = "Name of the container registry."
  value       = azurerm_container_registry.this.name
}

output "login_server" {
  description = "Fully qualified login server (e.g. myacr.azurecr.io) for docker login / image refs."
  value       = azurerm_container_registry.this.login_server
}

output "identity_principal_id" {
  description = "Principal ID of the system-assigned identity, for CMK or AcrPull grants. Null if none."
  value       = try(azurerm_container_registry.this.identity[0].principal_id, null)
}

output "admin_username" {
  description = "Admin username (only populated when admin_enabled = true)."
  value       = try(azurerm_container_registry.this.admin_username, null)
}

output "admin_password" {
  description = "Admin password (only populated when admin_enabled = true)."
  value       = try(azurerm_container_registry.this.admin_password, null)
  sensitive   = true
}

output "private_endpoint_ip" {
  description = "Private IP of the registry's private endpoint NIC, if a private endpoint was created."
  value       = try(azurerm_private_endpoint.this[0].private_service_connection[0].private_ip_address, null)
}

How to use it

module "container_registry" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-container-registry?ref=v1.0.0"

  name                = "kloudvinprodacr01"
  resource_group_name = azurerm_resource_group.platform.name
  location            = "centralindia"
  sku                 = "Premium"

  # Hardened: no admin user, public access off, RBAC only.
  admin_enabled                 = false
  public_network_access_enabled = false
  retention_in_days             = 14
  zone_redundancy_enabled       = true

  # Low-latency pulls for a second region.
  georeplications = [
    {
      location                = "southindia"
      zone_redundancy_enabled = true
    }
  ]

  # Lock registry traffic to the platform subnet.
  private_endpoint = {
    subnet_id            = azurerm_subnet.private_endpoints.id
    private_dns_zone_ids = [azurerm_private_dns_zone.acr.id]
  }

  tags = {
    environment = "prod"
    owner       = "platform-team"
  }
}

# Downstream: let the AKS kubelet identity pull images from this registry.
resource "azurerm_role_assignment" "aks_acr_pull" {
  scope                = module.container_registry.id
  role_definition_name = "AcrPull"
  principal_id         = azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id
}

# And reference the login server in a Container App / Deployment image tag.
locals {
  api_image = "${module.container_registry.login_server}/api:1.4.2"
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/container_registry/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-container-registry?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/container_registry && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Globally unique ACR name (5-50 alphanumerics, no hyphens).
resource_group_name string Yes Resource group to create the registry in.
location string Yes Azure region for the primary registry.
sku string "Standard" No Basic, Standard, or Premium. Premium unlocks geo-rep, zones, private endpoints.
admin_enabled bool false No Enable admin username/password. Keep false.
public_network_access_enabled bool true No Allow the public endpoint. Set false with a private endpoint.
anonymous_pull_enabled bool false No Allow unauthenticated pulls.
zone_redundancy_enabled bool true No AZ-redundant primary registry (Premium only).
export_policy_enabled bool true No Allow artifact export/import (Premium only).
data_endpoint_enabled bool false No Dedicated data endpoints for firewall-scoped pulls (Premium only).
network_rule_bypass_option string "AzureServices" No AzureServices or None — trusted-service bypass (Premium only).
retention_in_days number 7 No Days before untagged manifests are purged (0-365; 0 disables).
trust_policy_enabled bool false No Enable content trust / image signing (Premium only).
identity_type string "SystemAssigned" No SystemAssigned, UserAssigned, both, or null.
identity_ids list(string) [] No User-assigned identity IDs (required when type includes UserAssigned).
allowed_ip_ranges list(string) [] No CIDR ranges allowed through the firewall (Premium only).
georeplications list(object) [] No Extra regions to replicate to (Premium only).
private_endpoint object null No Private endpoint config (subnet_id, optional name + DNS zone IDs).
tags map(string) {} No Tags for the registry and sub-resources.

Outputs

Name Description
id Resource ID of the container registry.
name Name of the container registry.
login_server Login server FQDN (e.g. myacr.azurecr.io) for docker login and image references.
identity_principal_id System-assigned identity principal ID, for CMK or AcrPull grants (null if none).
admin_username Admin username (only when admin_enabled = true).
admin_password Admin password, marked sensitive (only when admin_enabled = true).
private_endpoint_ip Private IP of the registry’s private endpoint NIC, if created.

Enterprise scenario

A retail platform runs AKS clusters in Central India and South India behind a single regional load balancer. The platform team deploys one Premium registry through this module with georeplications to South India and zone_redundancy_enabled = true, so both clusters pull images from their nearest replica with sub-second latency and survive a zonal outage. Public access is disabled and a private endpoint pins all registry traffic to the hub VNet, while each cluster’s kubelet identity gets AcrPull via the id output — no admin passwords, no registry credentials in any pipeline.

Best practices

TerraformAzureContainer RegistryModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading