Azure automated image build

Table Of Contents

Credits and sources

Microsoft Learn


Hashicorp docs


This article is about automating image builds in Azure. I am using Packer in this scenario and will use a Windows image for the examples. Linux builds use the same process. The goal is to make a customized image in Azure and place it in the Azure Compute Gallery for use with either AVD or Scale Sets.

I will walk through the process of using Packer on my machine and then move the process into GitHub Action for an automated build.

You should know how to use Terraform; if not, you can check out this blog series I made previously.

Getting started with Azure and Terraform - Part 1

The code for this blog post is on my GitHub. Follow the link below to find it.

The first thing I will do is create an Azure Compute Gallery. Since I don’t use the portal often, I will do this with Terraform. Below is the code needed to create this resource.

The file contains the resources I am deploying. I am creating an Azure App Registration and outputting the client ID and password. Remember, you should never export your password in clear text, but I wanted to include this in the demo. The code enables you to create the same environment I have now easily.

resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
  tags     = local.tags
resource "azurerm_shared_image_gallery" "main" {
  name                = var.gallery_name
  resource_group_name =
  location            = azurerm_resource_group.main.location
  tags                = local.tags
resource "azurerm_shared_image" "main" {
  name                = "w11_demo"
  gallery_name        =
  resource_group_name =
  location            = azurerm_resource_group.main.location
  os_type             = "Windows"
  hyper_v_generation  = "V2"

  identifier {
    publisher = "microsoftwindowsdesktop"
    offer     = "office-365"
    sku       = "win11-23h2-avd-m365"
resource "azuread_application" "main" {
  display_name            = "SP-GitHub-Automated-Image-Build-Demo"
  sign_in_audience        = "AzureADMyOrg"
  prevent_duplicate_names = true
resource "azuread_service_principal" "main" {
  client_id = azuread_application.main.client_id
resource "azurerm_role_assignment" "main" {
  scope                            =
  role_definition_name             = "Contributor"
  principal_id                     =
  skip_service_principal_aad_check = true
resource "azuread_application_password" "main" {
  application_id =
output "client_id" {
  value = azuread_application.main.client_id
output "password" {
  value = nonsensitive(azuread_application_password.main.value)

provider "azurerm" {
  features {}  

variable "resource_group_name" {
  description = "The name of the resource group in which the resources will be created."
  default     = "rg-automated-image-build"
variable "location" {
  description = "The Azure region in which the resources will be created."
  default     = "WestEurope"  
variable "gallery_name" {
  description = "The name of the gallery."
  default     = "gal_automated_image_build"  

locals {
  tags = {
    Environment = "Production"
    Owner       = "Martin"

The screenshot below shows the output of the “terraform apply” command I ran. The client ID and secret are unimportant; they are deleted from my environment when this post is published.

GitHub secrets

I created the following secrets to support the GitHub Action. The client ID and secrets are the output from the Terraform command, and I expect you already know the subscription and tenant ID.

Packer template

The next part is creating the Packer template. There are two options for writing these templates: one in JSON format and the other in HCL format. I have chosen the HCL format because I prefer the syntax in this format. I will briefly break down the code and explain what each part is.

The first file I have is named “init.pkr.hcl”. This file contains the plugins I am using in Packer. In this demo, I am using an Azure plugin and a Windows updates plugin so that my image will be up-to-date after my Packer build process.

packer {
  required_plugins {
    windows-update = {
      version = "0.15.0"
      source = ""
    azure = {
      version = ">= 2.0.4"
      source  = ""

Next, I have “locals.pkr.hcl”, which, like Terraform, contains all the local lookups I will use in the deployment. In this build process, I create the image versioning from today’s date and save it as a local variable.

locals {
  image_version = formatdate("", timestamp())
  tags = {
    Environment = "Production"
    Owner       = "Martin"

Then, I have the “variables.pkr.hcl” file, which, again, like Terraform, will contain some variables, making it easier to reuse my Packer build file. Remember to insert your subscription ID, tenant ID, client ID, and client secret from your environment.

variable "resource_group_name" {
  description = "The name of the resource group in which the resources will be created."
  default     = "rg-automated-image-build"
variable "location" {
  description = "The Azure region in which the resources will be created."
  default     = "WestEurope"  
variable "subscription_id" {
  description = "The subscription ID where the resources are located."
variable "gallery_name" {
  description = "The name of the gallery."
  default     = "gal_automated_image_build"  
variable "image_name" {
  description = "The name of the image."
  default     = "w11_demo"  
variable "tenant_id" {
  description = "The tenant id of my environment."
variable "client_id" {
  description = "The client id of the app registration."
variable "client_secret" {
  description = "The client secret for the app registration."

The final file in my Packer deployment is called “main.pkr.hcl,” you guessed it; it contains the resources I will deploy with Packer. This file has two sections, one named “source” and the other called “build.” The source part is where I define the image I want to deploy and where I will capture it, and the build section describes what I want to do with the image. I added my software installation, Windows update, and Sysprep steps in the build section. I added comments inside the file to make it easy to understand what I am doing in this Packer build.

source "azure-arm" "image" {  
  # This section defines my authentication to Azure  
  client_id       = var.client_id
  client_secret   = var.client_secret
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id
  # This section defines where I want to place my image after the build is complete. 
  shared_image_gallery_destination {
    subscription = var.subscription_id
    resource_group = var.resource_group_name
    gallery_name = var.gallery_name
    image_name = var.image_name
    image_version = local.image_version
    replication_regions = ["${var.location}"]
    storage_account_type = "Standard_LRS"

  # This section defines the source image that I want to use to build my image, and also how to connect to the VM to run the build.
  build_resource_group_name = var.resource_group_name
  os_type                   = "Windows"
  image_publisher           =  "microsoftwindowsdesktop"
  image_offer               = "office-365"
  image_sku                 = "win11-23h2-avd-m365"  
  vm_size                   = "Standard_D4s_v5"
  communicator              = "winrm"
  winrm_insecure            = true
  winrm_timeout             = "5m"
  winrm_use_ssl             = true
  winrm_username            = "packer"
  azure_tags                = local.tags

build {
  # Here I reference the source that I defined above
  sources = [""]

  # Here I install the custom software I want in the image
  provisioner "powershell" {
    inline = ["New-Item -ItemType Directory -Path 'c:/Software'",
      "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12",
      "Write-Output 'Installing Visual Studio Code'",
      "Invoke-RestMethod -uri '' -OutFile 'c:/Software/VSCodeSetup-x64-1.87.2.exe'",
      "Start-Process c:/Software/VSCodeSetup-x64-1.87.2.exe -ArgumentList '/VERYSILENT /NORESTART /MERGETASKS=!runcode' -Wait",
      "Write-Output 'Installing Git for Windows'",
      "Invoke-RestMethod -uri '' -OutFile 'c:/Software/Git-2.44.0-64-bit.exe'",
      "Start-Process c:/Software/Git-2.44.0-64-bit.exe -ArgumentList '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' -Wait"

  # Here I run Windows update
  provisioner "windows-update" {
    search_criteria = "IsInstalled=0"
    filters = [
      "exclude:$_.Title -like '*Preview*'",
    update_limit = 25

  # Here I run sysprep
  provisioner "powershell" {
    inline = ["while ((Get-Service RdAgent).Status -ne 'Running') { Start-Sleep -s 5 }",
     "while ((Get-Service WindowsAzureGuestAgent).Status -ne 'Running') { Start-Sleep -s 5 }",
     "& $env:SystemRoot\\System32\\Sysprep\\Sysprep.exe /oobe /generalize /quiet /quit",
     "while($true) { $imageState = Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup\\State | Select ImageState; if($imageState.ImageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE') { Write-Output $imageState.ImageState; Start-Sleep -s 10  } else { break } }"]

To run the build, I run the following commands from the root folder in the repository.

packer init ./Templates/W11
packer build -var 'client_id=INSERT CLIENT ID' -var 'client_secret=INSERT CLIENT SECRET' -var 'subscription_id=INSERT SUBSCRIPTION ID' -var 'tenant_id=INSERT TENANT ID' ./Templates/W11

GitHub Action

I don’t want to run the build on my computer every time, so I created a GitHub Action. Using GitHub Action will also allow others to update the image so we can share that responsibility. With the GitHub Action, I can also schedule an update to occur on a schedule so everything is automated after patch Tuesday, for instance.

Below is the yaml code for the Action.

name: 'Automated-Image-Builder'
      id-token: write
      contents: read

    runs-on: ubuntu-latest
    name: Run Packer
        ### AZURE Client details ###
        ARM_CLIENT_ID: ${{ secrets.ClientID }}
        ARM_SUBSCRIPTION_ID: ${{ secrets.SubscriptionID }}
        ARM_TENANT_ID: ${{ secrets.TenantID }}
        ARM_USE_OIDC: true
    - name: Checkout
      uses: actions/checkout@v3

    - name: Setup `packer`
      uses: hashicorp/setup-packer@main
      id: setup
        version: "latest"

    - name: Run `packer init`
      id: init
      run: "packer init ./Templates/W11"

    - name: Run `packer validate`
      id: validate
      run: "packer validate -var 'client_id=${{ secrets.CLIENTID }}' -var 'client_secret=${{ secrets.CLIENTSECRET }}' -var 'subscription_id=${{ secrets.SUBSCRIPTIONID }}' -var 'tenant_id=${{ secrets.TENANTID }}' ./Templates/W11"

    - name: Run `packer build`
      id: build
      run: "packer build -var 'client_id=${{ secrets.CLIENTID }}' -var 'client_secret=${{ secrets.CLIENTSECRET }}' -var 'subscription_id=${{ secrets.SUBSCRIPTIONID }}' -var 'tenant_id=${{ secrets.TENANTID }}' ./Templates/W11"

Using the new image

There are multiple purposes for the new image, such as Azure Virtual Desktop, Virtual Machine, and Virtual Machine Scale Sets. I will show you how to create a new virtual machine with the image as the base.

First, navigate to the Azure Compute Gallery and select the image definition called “W11_demo.”

Then, click “Create VM,” and fill out the required fields to fit your environment.


In this short demo, I have shown an easy way to create virtual machine images with custom software and save them in Azure Compute Gallery. With this technology, you can have versions of your image, so if any new software or patch breaks your environment, you can choose an older version to run until a new version is fixed. Scale sets and end-user computing environments like Azure Virtual Desktop, Citrix, and Parallels often use this approach.

All code for this demo is in my GitHub, and you are welcome to use it in your environments without any warranty or guarantee of operability.

I hope this post is helpful. As always, feedback is welcome, so reach out on any of my social media or mail.