An end-to-end solution for creating and managing custom VM images for Microsoft Dev Box using Azure Image Builder and Bicep. Deploy with a single command — or automate via CI/CD — to produce golden images stored in an Azure Compute Gallery.
The solution provisions an Azure Image Builder template that:
- Downloads artifacts from a storage account using a managed identity
- Runs customization scripts (
Entrypoint.ps1) to install software, apply settings, etc. - Cleans up (
Exitpoint.ps1) and deprovisions the VM via Sysprep - Distributes the resulting image to an Azure Compute Gallery, with optional multi-region replication
Two Bicep modules handle image template creation:
| Module | Use case |
|---|---|
aib.module.bicep |
Public networking — no VNet integration |
aib.module-private.bicep |
Private networking — build VM retrieves scripts via private endpoint |
- Infrastructure as Code — fully declarative Bicep templates with parameterized configuration
- Three networking modes — public, bring-your-own VNet, or fully provisioned VNet (NSGs, NAT gateways, Bastion, private endpoints)
- Multiple deployment options — Azure DevOps Pipelines, GitHub Actions, Azure Automation, or manual PowerShell
- Smart redeployment — detects code changes to avoid unnecessary template redeployments
- Key Vault integration — optionally pass secrets to customization scripts at build time
- Image replication — distribute images to multiple Azure regions
- Staging resource group — isolate build-time resources with automatic cleanup
Resource providers
Register the following resource providers on your subscription:
Microsoft.VirtualMachineImagesMicrosoft.ContainerInstance
Azure CLI:
az provider register --namespace Microsoft.VirtualMachineImages
az provider register --namespace Microsoft.ContainerInstanceAzure PowerShell:
Register-AzResourceProvider -ProviderNamespace Microsoft.VirtualMachineImages
Register-AzResourceProvider -ProviderNamespace Microsoft.ContainerInstanceRBAC and permissions
| Requirement | Details |
|---|---|
| Image distribution | The AIB identity needs permissions to distribute images on the Compute Gallery |
| Managed Identity Operator | The AIB identity needs Managed Identity Operator (or Microsoft.ManagedIdentity/userAssignedIdentities/assign/action) on the build VM's user-assigned identity. See the documentation |
| Staging resource group | If using a staging resource group, the Owner role must be assigned to the AIB identity |
VNet integration (optional)
When providing your own subnet, ensure:
-
The AIB managed identity has these VNet permissions:
Microsoft.Network/virtualNetworks/readMicrosoft.Network/virtualNetworks/subnets/join/action
-
The Private Link Service network policy is disabled on the subnet. See the documentation:
Azure CLI:
az network vnet subnet update \ --name <subnet_name> \ --vnet-name <vnet_name> \ --resource-group <resource_group> \ --disable-private-link-service-network-policies true
Azure PowerShell:
$subnet = '<subnet_name>' $net = @{ Name = '<vnet_name>' ResourceGroupName = '<resource_group>' } $vnet = Get-AzVirtualNetwork @net ($vnet | Select -ExpandProperty subnets | Where-Object {$_.Name -eq $subnet}).privateLinkServiceNetworkPolicies = "Disabled" $vnet | Set-AzVirtualNetwork
- Azure CLI (with Bicep extension) or Azure PowerShell (
Az.Accounts,Az.Storage,Az.ImageBuilder) - Bicep CLI (included with Azure CLI, or install standalone)
The IaC/ProvisionAll/aib.bicep template supports three networking modes, automatically inferred from the subnetId and virtualNetworkName parameters:
| Mode | subnetId |
virtualNetworkName |
Behavior |
|---|---|---|---|
| Public | (empty) | (empty) | No VNet. Uses the public storage module |
| Bring-your-own VNet | provided | (ignored) | Your subnet IDs are passed to the private storage module. Networking module is not deployed |
| Provision VNet | (empty) | provided | Full networking stack is deployed: VNet, subnets, NAT gateways, NSGs, Bastion, and storage private endpoint |
Note
When subnetId is provided, virtualNetworkName is ignored — bring-your-own VNet always takes precedence.
The bastionSkuName parameter controls the Azure Bastion SKU (Basic, Standard, or Developer). When set to Developer, the NSG rule on VMBuilderSubnet automatically adds 168.63.129.16/32 as a source, since Developer SKU Bastion connects from the Azure platform IP instead of the Bastion subnet.
Warning
The Developer SKU is a free-tier offering intended for dev/test scenarios only. It does not provide an SLA, does not require a public IP or dedicated AzureBastionSubnet, and must not be used in production. See the Azure Bastion SKU comparison for details.
Dev Box requires compatible images. List available base images:
Azure CLI:
az devcenter admin image list --dev-center-name <dev_center_name> --resource-group <resource_group> --query "[].name"Azure PowerShell:
Get-AzDevCenterAdminImage -DevCenterName <dev_center_name> -ResourceGroupName <resource_group> | Select-Object -ExpandProperty NameTranslate a Dev Box image name to an ImageTemplateSource object. For example, microsoftwindowsdesktop_windows-ent-cpc_win11-24h2-ent-cpc becomes:
{
"type": "PlatformImage",
"publisher": "MicrosoftWindowsDesktop",
"offer": "windows-ent-cpc",
"sku": "win11-24h2-ent-cpc",
"version": "latest"
}Tip
Use the HelperScripts/Get-AzImageInfo.ps1 script for an interactive picker that outputs the correct JSON.
Place your customization logic in Scripts/Entrypoint.ps1 (runs during image build) and Scripts/Exitpoint.ps1 (cleanup before Sysprep). Both scripts receive -SubscriptionId and optionally -KeyVaultName / -KeyVaultSecretName parameters.
See the Scripts/Examples/ folder for ready-to-use examples, including Dev Box post-setup tasks.
Tip
Add the sha256Checksum property to customizers in aib.module.bicep to ensure script integrity:
(Get-FileHash -Path .\Scripts\DownloadArtifacts.ps1 -Algorithm Sha256).HashUse the pipeline definition in Deployment/azure-pipeline.yaml. It automatically detects whether template redeployment is needed before deploying and running the image build.
Use the workflow in Deployment/github-action.yaml. Configure the following secrets and variables in your repository:
| Type | Name |
|---|---|
| Secret | AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID |
| Variable | RESOURCE_GROUP_NAME, LOCATION |
Use Deployment/AzureAutomation-Runbook.ps1 as a runbook. It downloads the Bicep templates from a storage account, compiles and deploys them, then runs the image template — all using managed identity.
Bring your own resources
Azure CLI:
az deployment group create \
--resource-group <resource_group> \
--template-file ./IaC/BringYourOwnResources/aib.bicep \
--parameters <path/to/aib-parameters.jsonc> \
--verboseAzure PowerShell:
New-AzResourceGroupDeployment `
-ResourceGroupName <resource_group> `
-TemplateFile ./IaC/BringYourOwnResources/aib.bicep `
-TemplateParameterFile <path/to/aib-parameters.jsonc> `
-VerboseFull (provision all resources)
Azure CLI:
az deployment sub create \
--location <location> \
--name <deployment_name> \
--template-file ./IaC/ProvisionAll/aib.bicep \
--parameters <path/to/aib.parameters.json> \
--verboseAzure PowerShell:
New-AzDeployment `
-Location <location> `
-Name <deployment_name> `
-TemplateFile ./IaC/ProvisionAll/aib.bicep `
-TemplateParameterFile <path/to/aib.parameters.json> `
-VerboseAfter deploying the template, trigger the image build with Deployment/Invoke-ImageTemplate.ps1:
./Deployment/Invoke-ImageTemplate.ps1 `
-ResourceGroupName <resource_group> `
-ImageTemplateName <image_template_name> `
-OutputLogsBuild logs are stored in the staging resource group's storage account under the packerlogs blob container. Download the log file to review the full build process.
Tip
CMTrace (found under SMSSETUP\Tools after extraction) provides a more readable log viewing experience.
For deeper troubleshooting, you can deploy a standalone Windows VM into the same VMBuilderSubnet used by Image Builder. This lets you RDP into the network (via Bastion) and manually test scripts, verify connectivity to the storage account, or inspect private endpoints.
Deploy using the IaC/DebugVM/main.bicep template:
Note
The admin password must be 12–123 characters long and meet 3 of 4 complexity requirements: lowercase, uppercase, digit, and special character.
Quick generate with PowerShell:
# Exclude ambiguous or problematic symbols by adding them to the filter
$exclude = [char[]]'`"''{}[]|'
$chars = (33..126 | ForEach-Object { [char]$_ }) | Where-Object { $_ -notin $exclude }
$pwd = -join ($chars | Get-Random -Count 16); Write-Host $pwdAzure CLI:
az deployment group create \
--resource-group <resource_group> \
--template-file ./IaC/DebugVM/main.bicep \
--parameters ./IaC/DebugVM/main.parameters.jsonc \
--parameters adminPassword=<admin_password> \
--verboseAzure PowerShell:
New-AzResourceGroupDeployment `
-ResourceGroupName <resource_group> `
-TemplateFile ./IaC/DebugVM/main.bicep `
-TemplateParameterFile ./IaC/DebugVM/main.parameters.jsonc `
-adminPassword (Read-Host -AsSecureString 'Admin Password') `
-VerboseImportant
If you modify files referenced in the image template customizers, you must delete and recreate the template. Azure Image Builder copies those files to the staging resource group at provisioning time and does not detect changes. Applies only in public mode.
Warning
Azure Image Builder does not support service endpoints or private endpoints by design. When using private networking, the build VM retrieves scripts through a private endpoint managed by the aib.module-private.bicep module, not through the built-in File customizer.
Warning
When prepopulateStorageWithExampleScripts is set to true, the storage account's public network access remains enabled so the deployment script can upload example files. Additionally, the deploymentScripts resource provisions a temporary storage account (managed by the platform) with allowSharedKeyAccess enabled, because Azure Container Instances (ACI) can only mount file shares via an access key. If you set prepopulateStorageWithExampleScripts to false and use private networking (subnetId or virtualNetworkName), public network access is automatically disabled and the storage account is only accessible via private endpoints.
IaC/
├── aib.module.bicep # Image template (public networking)
├── aib.module-private.bicep # Image template (private networking)
├── BringYourOwnResources/ # Deploy into existing infra
├── ProvisionAll/ # Deploy everything from scratch
│ ├── aib.bicep # Main orchestrator
│ ├── networking.bicep # VNet, NSGs, NAT, Bastion, PE
│ ├── associatedresources.module.bicep
│ ├── stagingresources.module.bicep
│ └── shared.bicep
└── DebugVM/ # Standalone VM for debugging
Scripts/
├── Entrypoint.ps1 # Build-time customizations
├── Exitpoint.ps1 # Cleanup before Sysprep
├── DownloadArtifacts.ps1 # Managed-identity artifact download
├── DeprovisioningScript.ps1 # Sysprep generalization
└── Examples/ # Sample customization scripts
Deployment/
├── azure-pipeline.yaml # Azure DevOps pipeline
├── github-action.yaml # GitHub Actions workflow
├── AzureAutomation-Runbook.ps1 # Azure Automation runbook
├── Invoke-ImageTemplate.ps1 # Run the image template
└── Get-CodeChanges.ps1 # Detect changes for redeployment
HelperScripts/
├── Get-AzImageInfo.ps1 # Interactive image source picker
└── HelperFunctions.ps1 # Shared utilities
