IaC Azure

Terraform Module: Azure Shared Image Gallery — Golden Image Distribution with Replication and RBAC

Quick take — Build a reusable Terraform module for Azure Shared Image Gallery (Azure Compute Gallery) with image definitions, multi-region replication, RBAC sharing, and trusted-launch security to ship golden VM images at scale. 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_gallery" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-image-gallery?ref=v1.0.0"

  gallery_name        = "..."  # Gallery name; alphanumerics/dots/underscores only, no h…
  resource_group_name = "..."  # Resource group for the gallery and image definitions.
  location            = "..."  # Home (source) region for the gallery, e.g. `centralindi…
}

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

What this module is

Azure Shared Image Gallery — rebranded by Microsoft as Azure Compute Gallery — is the managed service for storing, versioning, and globally distributing custom VM images and VM Application packages. A gallery (azurerm_shared_image_gallery) is the top-level container; underneath it you create image definitions (one per OS/publisher/offer/SKU + generation combination) and image versions that get replicated to the regions where you actually deploy VMs and VM Scale Sets.

The gallery itself is a small resource with a deceptively large blast radius. Get its naming, region replication, or sharing model wrong and every team pulling a golden image inherits the mistake — slow cold-starts in far regions, images that can’t boot because the hyper-V generation doesn’t match, or a gallery that’s silently shared tenant-wide. Wrapping it in a reusable module pins one opinionated, security-reviewed pattern: trusted-launch-ready image definitions, explicit replica counts, RBAC-based sharing to consumer subscriptions, and consistent tags so FinOps can attribute the (otherwise easy-to-forget) replica storage cost. Consumers pass a handful of variables; the module guarantees the gallery, its definitions, and the sharing posture are correct every time.

When to use it

Reach for a plain azurerm_image (managed image) instead only when you have a single image, in a single region, that never needs versioning or sharing — almost never the case at enterprise scale.

Module structure

terraform-module-azure-image-gallery/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # gallery, image definitions, RBAC sharing
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # gallery id/name + image definition map

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

# -----------------------------------------------------------------------------
# Azure Compute Gallery (Shared Image Gallery)
# -----------------------------------------------------------------------------
resource "azurerm_shared_image_gallery" "this" {
  name                = var.gallery_name
  resource_group_name = var.resource_group_name
  location            = var.location
  description         = var.description

  # Community gallery sharing is opt-in and must be explicit. When enabled,
  # Azure requires publisher metadata; we only set the block when sharing
  # is configured to "Community".
  dynamic "sharing" {
    for_each = var.sharing_permission == null ? [] : [1]
    content {
      permission = var.sharing_permission

      dynamic "community_gallery" {
        for_each = var.sharing_permission == "Community" ? [1] : []
        content {
          eula            = var.community_gallery.eula
          prefix          = var.community_gallery.prefix
          publisher_email = var.community_gallery.publisher_email
          publisher_uri   = var.community_gallery.publisher_uri
        }
      }
    }
  }

  tags = var.tags
}

# -----------------------------------------------------------------------------
# Image definitions (one per OS/publisher/offer/SKU + generation)
# -----------------------------------------------------------------------------
resource "azurerm_shared_image" "this" {
  for_each = var.image_definitions

  name                = each.key
  gallery_name        = azurerm_shared_image_gallery.this.name
  resource_group_name = var.resource_group_name
  location            = var.location

  os_type           = each.value.os_type
  hyper_v_generation = each.value.hyper_v_generation
  architecture      = each.value.architecture

  # Security type controls Trusted Launch / Confidential VM support.
  # TrustedLaunchSupported is the safe default for V2 Gen images.
  trusted_launch_supported       = each.value.security_type == "TrustedLaunchSupported"
  confidential_vm_supported      = each.value.security_type == "ConfidentialVmSupported"
  accelerated_network_support_enabled = each.value.accelerated_networking

  identifier {
    publisher = each.value.publisher
    offer     = each.value.offer
    sku       = each.value.sku
  }

  dynamic "purchase_plan" {
    for_each = each.value.purchase_plan == null ? [] : [each.value.purchase_plan]
    content {
      name      = purchase_plan.value.name
      publisher = purchase_plan.value.publisher
      product   = purchase_plan.value.product
    }
  }

  min_recommended_vcpu_count    = each.value.min_vcpu
  max_recommended_vcpu_count    = each.value.max_vcpu
  min_recommended_memory_in_gb  = each.value.min_memory_gb
  max_recommended_memory_in_gb  = each.value.max_memory_gb

  end_of_life_date = each.value.end_of_life_date

  tags = var.tags
}

# -----------------------------------------------------------------------------
# RBAC: grant consumer principals data-plane Reader on the gallery so they
# can deploy VMs from image versions across subscriptions in the same tenant.
# -----------------------------------------------------------------------------
resource "azurerm_role_assignment" "reader" {
  for_each = toset(var.reader_principal_ids)

  scope                = azurerm_shared_image_gallery.this.id
  role_definition_name = "Reader"
  principal_id         = each.value
}

variables.tf

variable "gallery_name" {
  description = "Name of the Shared Image Gallery. Only alphanumerics, dots, and underscores are allowed (no hyphens), 1-80 chars."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9][A-Za-z0-9._]{0,79}$", var.gallery_name)) && !can(regex("-", var.gallery_name))
    error_message = "gallery_name must be 1-80 chars, alphanumerics/dots/underscores only — hyphens are NOT permitted in gallery names."
  }
}

variable "resource_group_name" {
  description = "Resource group that will hold the gallery and its image definitions."
  type        = string
}

variable "location" {
  description = "Azure region for the gallery's home (source) location, e.g. centralindia."
  type        = string
}

variable "description" {
  description = "Human-readable description of the gallery's purpose."
  type        = string
  default     = "Managed by Terraform — golden image distribution gallery."
}

variable "image_definitions" {
  description = "Map of image definitions keyed by definition name. Each entry describes one OS/publisher/offer/SKU + Hyper-V generation."
  type = map(object({
    os_type                = string                # "Linux" or "Windows"
    hyper_v_generation     = optional(string, "V2") # "V1" or "V2"
    architecture           = optional(string, "x64") # "x64" or "Arm64"
    security_type          = optional(string, "TrustedLaunchSupported")
    accelerated_networking = optional(bool, true)
    publisher              = string
    offer                  = string
    sku                    = string
    min_vcpu               = optional(number)
    max_vcpu               = optional(number)
    min_memory_gb          = optional(number)
    max_memory_gb          = optional(number)
    end_of_life_date       = optional(string) # RFC3339, e.g. "2027-01-01T00:00:00Z"
    purchase_plan = optional(object({
      name      = string
      publisher = string
      product   = string
    }))
  }))
  default = {}

  validation {
    condition = alltrue([
      for d in values(var.image_definitions) :
      contains(["Linux", "Windows"], d.os_type)
    ])
    error_message = "Each image definition os_type must be either \"Linux\" or \"Windows\"."
  }

  validation {
    condition = alltrue([
      for d in values(var.image_definitions) :
      contains(["V1", "V2"], d.hyper_v_generation)
    ])
    error_message = "hyper_v_generation must be \"V1\" or \"V2\". Trusted Launch and Confidential VM require \"V2\"."
  }

  validation {
    condition = alltrue([
      for d in values(var.image_definitions) :
      contains(["TrustedLaunchSupported", "ConfidentialVmSupported", "Standard"], d.security_type)
    ])
    error_message = "security_type must be one of: TrustedLaunchSupported, ConfidentialVmSupported, Standard."
  }
}

variable "sharing_permission" {
  description = "Gallery sharing model: \"Private\", \"Groups\" (RBAC/tenant), or \"Community\". Leave null to keep the provider default (Private)."
  type        = string
  default     = null

  validation {
    condition     = var.sharing_permission == null || contains(["Private", "Groups", "Community"], var.sharing_permission)
    error_message = "sharing_permission must be null, \"Private\", \"Groups\", or \"Community\"."
  }
}

variable "community_gallery" {
  description = "Required when sharing_permission is \"Community\". EULA, public name prefix, and publisher contact details."
  type = object({
    eula            = string
    prefix          = string
    publisher_email = string
    publisher_uri   = string
  })
  default = null
}

variable "reader_principal_ids" {
  description = "Object IDs (service principals, groups, or users) granted data-plane Reader on the gallery for cross-subscription image consumption."
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Tags applied to the gallery and all image definitions. Include a cost-center / owner tag — replica storage is billed."
  type        = map(string)
  default     = {}
}

outputs.tf

output "gallery_id" {
  description = "Resource ID of the Shared Image Gallery (Compute Gallery)."
  value       = azurerm_shared_image_gallery.this.id
}

output "gallery_name" {
  description = "Name of the Shared Image Gallery — pass this to image-version builders."
  value       = azurerm_shared_image_gallery.this.name
}

output "unique_gallery_name" {
  description = "Globally unique name Azure assigns the gallery (used in community/shared gallery URIs)."
  value       = azurerm_shared_image_gallery.this.unique_name
}

output "image_definition_ids" {
  description = "Map of image definition name => resource ID, for wiring image versions and VMSS source_image_id."
  value       = { for k, v in azurerm_shared_image.this : k => v.id }
}

output "image_definitions" {
  description = "Map of image definition name => key attributes (id, os_type, hyper_v_generation) for downstream consumption."
  value = {
    for k, v in azurerm_shared_image.this : k => {
      id                 = v.id
      os_type            = v.os_type
      hyper_v_generation = v.hyper_v_generation
    }
  }
}

How to use it

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

  gallery_name        = "kv_platform_images"
  resource_group_name = azurerm_resource_group.platform.name
  location            = "centralindia"
  description         = "Golden images for the KloudVin platform fleet."

  # Share to the tenant via RBAC (Groups), then grant the workload
  # subscriptions' deployment SPs Reader below.
  sharing_permission   = "Groups"
  reader_principal_ids = [
    azuread_service_principal.prod_deploy.object_id,
    data.azuread_group.platform_engineers.object_id,
  ]

  image_definitions = {
    "ubuntu-2204-hardened" = {
      os_type            = "Linux"
      hyper_v_generation = "V2"
      security_type      = "TrustedLaunchSupported"
      publisher          = "kloudvin"
      offer              = "ubuntu-server"
      sku                = "22_04-lts-hardened"
      min_vcpu           = 2
      max_vcpu           = 32
      min_memory_gb      = 4
      max_memory_gb      = 128
      end_of_life_date   = "2027-06-01T00:00:00Z"
    }

    "win2022-datacenter" = {
      os_type            = "Windows"
      hyper_v_generation = "V2"
      security_type      = "TrustedLaunchSupported"
      publisher          = "kloudvin"
      offer              = "windows-server"
      sku                = "2022-datacenter-g2"
    }
  }

  tags = {
    environment = "platform"
    cost_center = "cc-1042"
    owner       = "platform-engineering"
  }
}

# Downstream: a VMSS boots Ubuntu node images straight from the gallery
# image definition exposed by the module output. The image *version*
# (e.g. "latest") is resolved at deploy time.
resource "azurerm_linux_virtual_machine_scale_set" "nodes" {
  name                = "vmss-app-nodes"
  resource_group_name = azurerm_resource_group.workload.name
  location            = "centralindia"
  sku                 = "Standard_D4s_v5"
  instances           = 3
  admin_username      = "azureuser"

  # Use the image definition ID from the module; "latest" pins to the
  # newest replicated version in this region.
  source_image_id = "${module.shared_image_gallery.image_definition_ids["ubuntu-2204-hardened"]}/versions/latest"

  secure_boot_enabled = true
  vtpm_enabled        = true

  admin_ssh_key {
    username   = "azureuser"
    public_key = file("~/.ssh/id_rsa.pub")
  }

  network_interface {
    name    = "nic-app-nodes"
    primary = true
    ip_configuration {
      name      = "internal"
      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_gallery/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-gallery?ref=v1.0.0"
}

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

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

cd live/prod/image_gallery && 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
gallery_name string Yes Gallery name; alphanumerics/dots/underscores only, no hyphens, 1-80 chars.
resource_group_name string Yes Resource group for the gallery and image definitions.
location string Yes Home (source) region for the gallery, e.g. centralindia.
description string "Managed by Terraform — golden image distribution gallery." No Description of the gallery’s purpose.
image_definitions map(object(...)) {} No Map of image definitions (OS, generation, architecture, security type, identifier, recommendations).
sharing_permission string null No Sharing model: Private, Groups, or Community (null keeps provider default).
community_gallery object(...) null No EULA + publisher metadata; required when sharing_permission = "Community".
reader_principal_ids list(string) [] No Object IDs granted data-plane Reader for cross-subscription image consumption.
tags map(string) {} No Tags for the gallery and all image definitions (include a cost-center tag).

Outputs

Name Description
gallery_id Resource ID of the Shared Image Gallery.
gallery_name Gallery name — pass to image-version builders (Packer/AIB).
unique_gallery_name Globally unique gallery name Azure assigns (used in shared/community URIs).
image_definition_ids Map of image definition name => resource ID for VMSS source_image_id and image-version wiring.
image_definitions Map of image definition name => { id, os_type, hyper_v_generation }.

Enterprise scenario

A retail bank’s platform team maintains hardened Ubuntu and Windows Server golden images that every product squad must boot from for PCI-DSS compliance. They deploy this module once in a central “platform-images” subscription with sharing_permission = "Groups" and grant each squad’s deployment service principal Reader via reader_principal_ids. Azure Image Builder publishes new monthly versions into the module’s image definitions and replicates them to centralindia, southindia, and westeurope; squads in any of those regions reference image_definition_ids["ubuntu-2204-hardened"]/versions/latest and automatically pick up the newest patched, secure-boot-enabled image without ever copying a VHD.

Best practices

TerraformAzureShared Image GalleryModuleIaC
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