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
- You run more than one ACR (per-environment dev/test/prod, or per-team) and want them provisioned identically without copy-pasting HCL.
- You are pulling images into AKS, Azure Container Apps, App Service, or Container Instances and need a registry whose
login_serverand managed identity flow cleanly into arole_assignment(AcrPull). - You need production hardening: no admin user, RBAC/token-based auth, retention of untagged manifests, and network isolation via a private endpoint or IP allow-list.
- You want Premium capabilities — geo-replication for multi-region pulls, availability-zone redundancy, or content trust — without hand-writing the replication and policy blocks each time.
- Skip it (or use
Basicmode) for a throwaway sandbox where a single-region, public registry is acceptable and the extra Premium cost is not justified.
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 config — live/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 config — live/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
- Never enable the admin user. Keep
admin_enabled = falseand authenticate with Microsoft Entra RBAC (AcrPull/AcrPushrole assignments) or scoped repository tokens. A leaked admin password grants full push access to every repository. - Go private in production. Set
public_network_access_enabled = falseand attach aprivate_endpointwith a linkedprivatelink.azurecr.ioDNS zone; if you must stay public, restrict withallowed_ip_rangesrather than leaving the firewall open. - Turn on retention to control cost. Untagged manifests from CI accumulate fast — a
retention_in_daysof 7-30 reclaims storage automatically, and Premium storage is billed per GB so this directly trims the bill. - Match the SKU to the need. Geo-replication, zone redundancy, private endpoints, and CMK are Premium-only; don’t pay for Premium in dev where
Standard(orBasic) is enough. Reserve geo-replication for genuinely multi-region pull paths. - Name registries deterministically. ACR names are globally unique and alphanumeric-only — use a
{org}{env}acr{nn}convention (e.g.kloudvinprodacr01) so collisions and ownership are obvious at a glance. - Scan and sign what you ship. Enable Microsoft Defender for Containers for vulnerability scanning on push, and for high-trust supply chains set
trust_policy_enabled = trueto require signed images.