IaC Azure

Terraform Module: Azure Compute Gallery Image Version — versioned, multi-region golden images

Quick take — Reusable hashicorp/azurerm module to publish azurerm_shared_image_version into an Azure Compute Gallery: target-region replication, ZRS storage, end-of-life dates, and exclude-from-latest controls. 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 "image_builder" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-image-builder?ref=v1.0.0"

  image_version         = "..."           # Semantic version `MAJOR.MINOR.PATCH` (e.g. `1.0.0`, `20…
  gallery_name          = "..."           # Existing Azure Compute Gallery that owns the definition.
  image_definition_name = "..."           # Existing image definition to publish under.
  resource_group_name   = "..."           # Resource group containing the gallery.
  location              = "..."           # Region of the gallery / definition; always a replicatio…
  target_regions        = ["...", "..."]  # Replication targets with per-region replica count and o…
}

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

What this module is

In Azure Compute Gallery (formerly Shared Image Gallery), an image definition is the logical product line — it carries the publisher/offer/SKU, the OS type, the generation (V1 / V2 Gen2), and the architecture. It holds no bits on its own. An image version is a concrete, immutable, numbered artifact (1.0.0, 2026.06.09) under that definition: it is the thing a VM, a Scale Set, or AKS actually boots from. azurerm_shared_image_version is the resource that publishes one.

The version is where every interesting production decision lives: which target regions the image replicates into and how many replica copies sit in each, whether the underlying replica blobs use Standard_LRS, Standard_ZRS, or Premium_LRS, the end-of-life date after which the version should be retired, and whether the version is excluded from “latest” so that version = "latest" selectors skip a canary build. Get those wrong and you either pay for replicas in regions nobody deploys to, throttle a 500-node Scale Set rollout because you under-provisioned replica count, or accidentally promote an unfinished image to your entire fleet.

Wrapping azurerm_shared_image_version in a module makes the publish step declarative and uniform. Every team that bakes an image — whether the source is a managed image, a captured VM, or a VHD blob in a storage account — feeds the same variable contract: source artifact, target-region map, storage tier, EOL date. The module enforces sane defaults (ZRS replicas in paired regions, a forced EOL date), exposes the version id for downstream VM/VMSS modules, and keeps replication policy out of copy-pasted HCL.

When to use it

If you only need the gallery container or the image definition (the publisher/offer/SKU shell), use modules around azurerm_shared_image_gallery and azurerm_shared_image instead — this module assumes the definition already exists and concerns itself purely with publishing and replicating a version.

Module structure

terraform-module-azure-image-builder/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_shared_image_version + target_region replication
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # version id/name + replication metadata
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}
# main.tf

locals {
  # Replica blob SKU per target region; fall back to the module-level default.
  default_storage_account_type = var.storage_account_type

  # Build the ordered, deduplicated set of target regions. The image
  # definition's own location is always required as a replication target.
  target_regions = {
    for tr in var.target_regions : tr.name => tr
  }
}

resource "azurerm_shared_image_version" "this" {
  name                = var.image_version
  gallery_name        = var.gallery_name
  image_name          = var.image_definition_name
  resource_group_name = var.resource_group_name
  location            = var.location

  # Exactly one source must be supplied. Prefer a managed image or another
  # gallery version; VHD blobs are supported via blob_uri/storage_account_id.
  managed_image_id                   = var.managed_image_id
  blob_uri                           = var.blob_uri
  storage_account_id                 = var.storage_account_id

  # Governance controls.
  exclude_from_latest = var.exclude_from_latest
  end_of_life_date    = var.end_of_life_date

  # Replication fan-out. Each target region gets its own replica count and
  # storage SKU so paired/DR regions can be zone-redundant while spoke
  # regions stay on cheaper LRS.
  dynamic "target_region" {
    for_each = local.target_regions
    content {
      name                   = target_region.value.name
      regional_replica_count = target_region.value.regional_replica_count
      storage_account_type   = coalesce(target_region.value.storage_account_type, local.default_storage_account_type)
    }
  }

  tags = var.tags
}
# variables.tf

variable "image_version" {
  description = "Semantic image version, MAJOR.MINOR.PATCH (e.g. 1.0.0 or 2026.6.9)."
  type        = string

  validation {
    condition     = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.image_version))
    error_message = "image_version must be three dot-separated integers, e.g. 1.0.0."
  }
}

variable "gallery_name" {
  description = "Name of the existing Azure Compute Gallery that owns the image definition."
  type        = string
}

variable "image_definition_name" {
  description = "Name of the existing image definition (publisher/offer/SKU shell) to publish under."
  type        = string
}

variable "resource_group_name" {
  description = "Resource group containing the Compute Gallery."
  type        = string
}

variable "location" {
  description = "Azure region of the gallery / image definition. Always a replication target."
  type        = string
}

variable "managed_image_id" {
  description = "Resource ID of a managed image to use as the source. Mutually exclusive with blob_uri."
  type        = string
  default     = null
}

variable "blob_uri" {
  description = "SAS-less blob URI of a VHD to use as the source. Requires storage_account_id."
  type        = string
  default     = null
}

variable "storage_account_id" {
  description = "Resource ID of the storage account backing blob_uri. Required when blob_uri is set."
  type        = string
  default     = null
}

variable "target_regions" {
  description = "Regions to replicate this version into, with per-region replica count and optional storage SKU override."
  type = list(object({
    name                   = string
    regional_replica_count = optional(number, 1)
    storage_account_type   = optional(string)
  }))

  validation {
    condition     = length(var.target_regions) > 0
    error_message = "At least one target_region is required (typically including the gallery's own region)."
  }

  validation {
    condition = alltrue([
      for tr in var.target_regions : tr.regional_replica_count >= 1 && tr.regional_replica_count <= 100
    ])
    error_message = "regional_replica_count must be between 1 and 100 per region."
  }

  validation {
    condition = alltrue([
      for tr in var.target_regions :
      tr.storage_account_type == null ? true : contains(["Standard_LRS", "Standard_ZRS", "Premium_LRS"], tr.storage_account_type)
    ])
    error_message = "Per-region storage_account_type must be one of Standard_LRS, Standard_ZRS, Premium_LRS."
  }
}

variable "storage_account_type" {
  description = "Default replica storage SKU applied to regions that do not override it."
  type        = string
  default     = "Standard_ZRS"

  validation {
    condition     = contains(["Standard_LRS", "Standard_ZRS", "Premium_LRS"], var.storage_account_type)
    error_message = "storage_account_type must be one of Standard_LRS, Standard_ZRS, Premium_LRS."
  }
}

variable "exclude_from_latest" {
  description = "When true, this version is skipped by version = \"latest\" selectors. Use for canary/soak builds."
  type        = bool
  default     = false
}

variable "end_of_life_date" {
  description = "RFC 3339 timestamp after which this version is considered retired (e.g. 2027-06-09T00:00:00Z)."
  type        = string
  default     = null

  validation {
    condition     = var.end_of_life_date == null ? true : can(formatdate("YYYY-MM-DD", var.end_of_life_date))
    error_message = "end_of_life_date must be an RFC 3339 timestamp, e.g. 2027-06-09T00:00:00Z."
  }
}

variable "tags" {
  description = "Tags applied to the image version."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "id" {
  description = "Resource ID of the published image version. Use as source_image_id on VMs/VMSS."
  value       = azurerm_shared_image_version.this.id
}

output "name" {
  description = "The published version number (e.g. 1.0.0)."
  value       = azurerm_shared_image_version.this.name
}

output "image_definition_name" {
  description = "Image definition this version was published under."
  value       = azurerm_shared_image_version.this.image_name
}

output "gallery_name" {
  description = "Compute Gallery that owns this version."
  value       = azurerm_shared_image_version.this.gallery_name
}

output "target_region_names" {
  description = "Regions this version is replicated into."
  value       = [for tr in var.target_regions : tr.name]
}

output "exclude_from_latest" {
  description = "Whether this version is excluded from latest selectors."
  value       = azurerm_shared_image_version.this.exclude_from_latest
}

How to use it

module "compute_gallery_image_version" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-image-builder?ref=v1.0.0"

  image_version         = "2026.6.9"
  gallery_name          = "kvgallery"
  image_definition_name = "ubuntu-2204-hardened-gen2"
  resource_group_name   = "rg-images-prod"
  location              = "centralindia"

  # Source produced by the upstream Packer build (a managed image).
  managed_image_id = azurerm_image.packer_output.id

  # Zone-redundant in the two regions we actually run prod; cheap LRS in the
  # DR-only region.
  storage_account_type = "Standard_ZRS"
  target_regions = [
    { name = "centralindia", regional_replica_count = 3 },
    { name = "southindia", regional_replica_count = 2 },
    { name = "southeastasia", regional_replica_count = 1, storage_account_type = "Standard_LRS" },
  ]

  # Auto-retire 12 months out; do NOT exclude — this is a promoted release.
  exclude_from_latest = false
  end_of_life_date    = "2027-06-09T00:00:00Z"

  tags = {
    environment = "prod"
    pipeline    = "golden-image"
    owner       = "platform-team"
  }
}

# Downstream: boot a Scale Set from the published version via its id output.
resource "azurerm_linux_virtual_machine_scale_set" "app" {
  name                = "vmss-app-prod"
  resource_group_name = "rg-app-prod"
  location            = "centralindia"
  sku                 = "Standard_D4s_v5"
  instances           = 6
  admin_username      = "azureadmin"

  source_image_id = module.compute_gallery_image_version.id

  network_interface {
    name    = "nic-app"
    primary = true
    ip_configuration {
      name      = "ipcfg"
      primary   = true
      subnet_id = azurerm_subnet.app.id
    }
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"
  }
}

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/image_builder/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  image_version = "..."
  gallery_name = "..."
  image_definition_name = "..."
  resource_group_name = "..."
  location = "..."
  target_regions = ["...", "..."]
}

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

cd live/prod/image_builder && 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
image_version string Yes Semantic version MAJOR.MINOR.PATCH (e.g. 1.0.0, 2026.6.9).
gallery_name string Yes Existing Azure Compute Gallery that owns the definition.
image_definition_name string Yes Existing image definition to publish under.
resource_group_name string Yes Resource group containing the gallery.
location string Yes Region of the gallery / definition; always a replication target.
managed_image_id string null No* Source managed image ID. Mutually exclusive with blob_uri.
blob_uri string null No* Source VHD blob URI. Requires storage_account_id.
storage_account_id string null No Storage account backing blob_uri.
target_regions list(object({name, regional_replica_count, storage_account_type})) Yes Replication targets with per-region replica count and optional SKU.
storage_account_type string "Standard_ZRS" No Default replica SKU for regions without an override.
exclude_from_latest bool false No Skip this version for latest selectors (canary/soak).
end_of_life_date string null No RFC 3339 retirement timestamp (e.g. 2027-06-09T00:00:00Z).
tags map(string) {} No Tags applied to the version.

* Exactly one source — managed_image_id or blob_uri (+ storage_account_id) — must be supplied.

Outputs

Name Description
id Resource ID of the published image version; use as source_image_id on VMs/VMSS.
name The published version number (e.g. 1.0.0).
image_definition_name Image definition the version was published under.
gallery_name Compute Gallery that owns the version.
target_region_names List of regions the version is replicated into.
exclude_from_latest Whether the version is excluded from latest selectors.

Enterprise scenario

A pan-India insurer runs a nightly Packer build that hardens Ubuntu 22.04 (Gen2 + Trusted Launch) against their CIS baseline, captures it to a managed image, then calls this module to publish a 2026.6.9-style version into kvgallery. Production workloads in Central India and South India each get 3 and 2 ZRS replicas so a zone outage never blocks an autoscale event, while the Singapore DR region carries a single LRS replica to keep cost down. The first nightly build is published with exclude_from_latest = true and soaks in a staging Scale Set for 24 hours; once smoke tests pass, the pipeline re-runs the module with the flag cleared, promoting it to every version = "latest" consumer fleet-wide, and sets end_of_life_date 12 months out so old hardened images are retired on a known schedule.

Best practices

TerraformAzureCompute Gallery Image VersionModuleIaC
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