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
- You bake golden OS images with Packer or Azure Image Builder and need to distribute them to multiple regions and subscriptions.
- You run VM Scale Sets or AKS node pools that must boot from a versioned, immutable image rather than a one-off managed image.
- You want Trusted Launch / Confidential VM images (
hyper_v_generation = "V2", secure boot, vTPM) standardised across the fleet. - You need to share images across subscriptions in the same tenant via RBAC (the
Readerdata-plane role) instead of copy-pasting VHDs. - You want replica counts, target regions, and lifecycle tags to be a reviewable input, not a click in the portal.
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 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_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
- Never put hyphens in the gallery name. Azure Compute Gallery names reject
-(unlike almost every other Azure resource), so the module validation fails fast rather than at apply time — use dots or underscores to separate words. - Match
hyper_v_generationto the workload. Trusted Launch, Confidential VMs, secure boot, and vTPM all requireV2Gen-2 image definitions; building aV1definition then trying to boot a Trusted-Launch VMSS from it fails silently with a non-bootable instance. - Control replica storage cost explicitly. Each replicated image version is billed per region as a snapshot-equivalent — replicate only to regions you actually deploy in, set
end_of_life_dateon definitions, and tag everything with a cost center so FinOps can attribute the spend. - Share with RBAC
Groups, not Community, for internal fleets. Community sharing exposes a public prefix and EULA to the whole tenant ecosystem; for internal golden images usesharing_permission = "Groups"plus scopedReaderassignments so only your deployment principals can pull images. - Pin image versions in production, float them in dev. Use
/versions/latestfor non-prod auto-patching, but reference an explicit version (e.g..../versions/1.2.3) in production VMSS so a bad image build can’t roll out unreviewed. - Keep one definition per OS+offer+SKU+generation. Reusing a definition for a different OS family or generation breaks the identifier contract and confuses image-version targeting — model each variant as its own key in
image_definitions.