Azure DevOps – using Packer to create images

Recently I’ve been using Packer to create images for use with Citrix Cloud and Windows Virtual Desktop, and building this into a DevOps Pipeline, to automate creation of images based on a JSON template.

Within Azure DevOps this is a really simple process – and great for creating VM images within your Pipelines, or for Lab/Testing environments too. I use this for my Citrix/WVD Lab, which I often spin up/down on demand for demos/testing. This also means every time I create the lab – I have an up to date image!


Before we can use Packer, we need to setup a Service Principal within Azure Active Directory, and grant it permissions to the required Subscriptions.

I use the Azure CLI to do this:

az ad sp create-for-rbac -n "DevOpsPacker" --role Contributor --query "{ client_id: appId, client_secret: password, tenant_id: tenant }"

Note: you may also need to specify your Subscription ID or a specific Resource Group, in which case use –scopes and provide these in the usual way; /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-111122223333, or /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-111122223333/resourceGroups/GroupName

This command will provide an output like the below:

These values are required to be used by Packer so that it can authenticate to Azure. I use Pipeline Variables to set these, so that my JSON file does not need to have them in – more on this later.

Packer JSON File

Within our Repo, we will need to provide a JSON file that defines our Packer configuration – essentially a list of elements that will be interpreted by Packer to create our image. For this post, I am using the file below, which is also available in my GitHub Repo. This is a simple Packer JSON configuration – which uses Windows 10 20H2 for WVD, and also uses Chocolately to install a number of applications. Finally – sysprep is run, and the image is generalized, ready to use.

    "builders": [{
      "type": "azure-arm",
      "client_id": "__client_id__",
      "client_secret": "__client_secret__",
      "subscription_id": "__subscription_id__",
      "managed_image_resource_group_name": "demolabv1-packer",
      "managed_image_name": "DemoLabPackerImage",
      "os_type": "Windows",
      "image_publisher": "MicrosoftWindowsDesktop",
      "image_offer": "Windows-10",
      "image_sku": "20h2-evd",
      "communicator": "winrm",
      "winrm_use_ssl": true,
      "winrm_insecure": true,
      "winrm_timeout": "5m",
      "winrm_username": "packer",
      "azure_tags": {
          "environment": "demolabv1-packer"
      "build_resource_group_name": "demolabv1-packer",
      "vm_size": "Standard_D2s_v4"
    "provisioners": [{
      "type": "powershell",
      "inline": [
        "while ((Get-Service RdAgent).Status -ne 'Running') { Start-Sleep -s 5 }",
        "while ((Get-Service WindowsAzureGuestAgent).Status -ne 'Running') { Start-Sleep -s 5 }",
        "Set-ExecutionPolicy Bypass -Scope Process -Force",
        "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12",
        "iex ((New-Object System.Net.WebClient).DownloadString(''))",
        "choco install notepadplusplus -y --force --force-dependencies",
        "choco install putty -y --force --force-dependencies",
        "choco install winscp -y --force --force-dependencies",
        "choco install 7zip -y --force --force-dependencies",
        "choco install firefox -y --force --force-dependencies",
        "choco install audacity -y --force --force-dependencies",
        "choco install fslogix -y --force --force-dependencies",
        "& $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 } }"

Azure DevOps – Pipeline Variables

As you’ll have seen, in the above Packer JSON file, the 3 required variables ( “client_id“, “client_secret“, and “subscription_id“) have been replaced with __variable__. This is so that within our Repo, we don’t need to store any sensitive data – this is all contained within the Release Pipeline using tokens and a token replacement task. To set this up – we first need to define the variables within our Release Pipeline. As you can see below – it is case of using the variable values from the JSON (without the __ before and after) and then using the values for the Client ID, Client Secret, and Subscription ID from the Azure CLI command we ran earlier:

Once this is done – we can later use the Replace Tokens task to automatically update these in our JSON file before Packer is run.

Pipeline Tasks

Now we have our Service Principal, JSON configuration file, and have put our Variables into the Release Pipeline, we can setup the tasks that will build our image. Fortunately, this is a simple process – creating an Image with Packer is actually a single Task in a DevOps pipeline, however, I use three tasks – to ensure supporting resources, and the required token replacement also takes place.

  1. Task 1 – An Azure CLI command to create the supporting resources for Packer (all I am creating is a Resource Group)
  2. Task 2 – Replacement of the Tokens within the JSON file
  3. Task 3 – Creation of the immutable image using Packer
Task 1

Within Task 1 I am running the below Azure CLI command to create the Resource Group for Packer:

call az group create --location uksouth --name demolabv1-packer

The full task is shown below:

The YAML for this task is below:

- task: AzureCLI@1
  displayName: 'Azure CLI to deploy required Azure resources for Packer'
    azureSubscription: 'jwnetworks MSDN (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)'
    scriptLocation: inlineScript
    inlineScript: |
     # this will create Azure resource group
     call az group create --location uksouth --name demolabv1-packer
Task 2

In Task 2, we use the Token Replacement task to swap out our Tokens for the Variables within our DevOps Pipeline. This is done using the “Replace Tokens” task, shown below:

You’ll note that I have directed the task at my Packer directory, so it only attempts to modify files within the Packer directory, and I’ve scoped this to the file specifically for this Pipeline (wvdw10mu.json).

The YAML for this Task is below:

- task: qetza.replacetokens.replacetokens-task.replacetokens@3
  displayName: 'Replace tokens in packer file'
    rootDirectory: '$(System.DefaultWorkingDirectory)/Terraform, Packer/Packer'
    targetFiles: wvdw10mu.json
    escapeType: none
    tokenPrefix: '__'
    tokenSuffix: '__'
Task 3

Finally, we are ready to use the “Build immutable image” task. This leverages Packer and uses our JSON file to create an image. The task is shown below:

The YAML for this Task is below:

- task: PackerBuild@1
  displayName: 'Build immutable image'
    templateType: custom
    customTemplateLocation: '$(System.DefaultWorkingDirectory)/Terraform, Packer/Packer/wvdw10mu.json'
    ConnectedServiceName: 'jwnetworks MSDN (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)'
    isManagedImage: false

As you can see – the task is very simple to setup and configure. We can now create a Release and allow our Pipeline to run:

Once completed, the task will provide an image that we can import into a Shared Image Gallery and use throughout our environment:

Conclusion & additional steps

Hopefully from this post, you can see how simple using Packer within Azure DevOps is! This is a great way to create environments or re-run Packer when creating a release, so that you always have an updated image ready to deploy. If you want to also include a Shared Image Gallery, and then import your newly created Packer image into it, you can use the below Azure CLI commands within your Release Pipeline:

call az sig create --gallery-name demolabv1images --resource-group demolabv1-images

call az sig image-definition create --resource-group demolabv1-images --gallery-name demolabv1images --gallery-image-definition Windows10MuPacker --publisher DemoLab --offer Windows10 --sku MultiUserPacker --os-type Windows --os-state generalized

call az sig image-version create -g demolabv1-images --storage-account-type Premium_LRS --gallery-name demolabv1images --gallery-image-definition Windows10MuPacker --gallery-image-version 1.0.0 --managed-image /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx/resourceGroups/demolabv1-packer/providers/Microsoft.Compute/images/DemoLabPackerImage

I use the above within a release Pipeline to create a Shared Image Gallery, an Image Definition, and then import the created Packer Image – so that I can deploy Citrix/WVD Session Hosts rapidly in my Lab, knowing that each time I spin up this lab, I have a new image that’s ready to go:

Hope this has been useful – see you next time! 🙂