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
- You run a golden-image pipeline (Packer, Azure VM Image Builder, or a capture script) and need a consistent, versioned hand-off into a gallery for VMs, Scale Sets, or AKS node images.
- You deploy the same image into multiple regions and want replication targets, per-region replica counts, and storage SKU expressed as data, not hand-edited blocks.
- You need lifecycle governance on images: enforced end-of-life dates, the ability to mark a build as
exclude_from_latestfor canary/soak, and clean retirement of old versions. - You want Gen2 / Trusted Launch images replicated as zone-redundant (ZRS) so a single zone outage in a region does not block VM provisioning.
- You are standardising image publishing across many definitions and teams and want one audited module instead of N bespoke
azurerm_shared_image_versionblocks.
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 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/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
- Pin versions, never float in prod. Have consuming VMs/VMSS reference the module’s
idoutput (a specific version) rather thanversion = "latest"; reservelatestfor dev. Useexclude_from_latest = trueto soak every new build before it can be picked up bylatestselectors. - Match replica count to rollout concurrency. Azure throttles per-replica image reads; a large Scale Set or AKS node pool scaling out from a single replica will serialize and slow down. Provision 1 replica per ~20 concurrent VM creations in each region, and only in regions you actually deploy to — every replica is billed storage.
- Use ZRS for paired/DR regions, LRS for spokes. Default to
Standard_ZRSso a single-zone failure does not stall provisioning in your primary regions; drop low-traffic or DR-only regions toStandard_LRSto cut replica storage cost roughly in half. - Always set
end_of_life_date. It does not auto-delete the version, but it surfaces retirement in the portal/API and lets a cleanup job reap stale images on a schedule — preventing gallery sprawl and replica bills for images no one boots anymore. - Keep versions immutable and forward-numbered. Never re-publish over an existing version number; bake date- or build-number-based versions (
2026.6.9) so rollbacks are just “deploy the previousid,” and the audit trail of what shipped when stays intact. - Tag and trace the source. Carry
pipeline,owner, and the source commit/build ID intagsso any running VM can be traced back to the exact hardened image and the run that produced it.