Deploying virtual machines using Azure Verified Modules with Bicep
Hi, and welcome. I thought I should write a piece about Bicep following a recommended approach that promotes modularity and consistency. In this guide, I'll walk through deploying a virtual machine using Azure Verified Modules (AVM).
What is AVM, you may ask? AVM is an initiative from Microsoft that sets standards for good infrastructure-as-code. These modules are supported by Microsoft and are consistently updated to align with Microsoft Best practices
For this demo, I won’t cover every single step, but I’m assuming you have some familiarity with Azure. Microsoft offers an excellent learning path for Bicep at https://learn.microsoft.com/en-us/training/paths/bicep-azure-pipelines/ which I highly recommend if you're just getting started.
I’m no Bicep expert, but I’ve spent enough time with the tools to feel confident using them and sharing what I’ve learned.
For more information, head over to aka.ms/AVM which contains documentation for all modules and updates
This is the module link for this guide:
Let's dive in..
Create Directory Structure
First, you want to create the folder/file structure as below, or if you're lazy, clone the repo below
/bicep
│
├── main.bicep # Entry point for your deployment
├── main.dev.bicepparam # Dev environment parameters
├── main.prod.bicepparam # Prod environment parameters
├── modules/
│ └── virtualMachine/
│ └── vm.bicep # (optional wrapper)
└── README.md
Create Virtual Machine Wrapper Module
First, we'll create the wrapper module. This will sit between the main.bicep and the AVM module. It provides a local abstraction layer that you can use for repeatability and enforcing standards, and custom defaults
For example, you might want to standardize certain values like the NIC suffix or IP configuration name across your environment:
e.g.
param nicSuffix string = '-nic-01'
param ipConfigName string = 'ipconfig01'
These values are hard-coded or defaulted in the wrapper module to promote consistency across deployments. By doing this, teams don’t need to remember or repeatedly define these details in every environment.
modules/virtualMachine/vm.bicep
@description('Name of the virtual machine')
param name string
@description('Location for the VM')
param location string
@description('Admin username')
param adminUsername string
@description('Admin password')
@secure()
param adminPassword string
@description('Subnet ID for NIC')
param subnetId string
@description('Image reference')
param imageReference object = {
offer: 'WindowsServer'
publisher: 'MicrosoftWindowsServer'
sku: '2022-datacenter-azure-edition'
version: 'latest'
}
@description('OS disk settings')
param osDisk object = {
caching: 'ReadWrite'
diskSizeGB: 128
managedDisk: {
storageAccountType: 'StandardSSD_LRS'
}
}
@description('VM size')
param vmSize string = 'Standard_D2s_v3'
module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.15.0' = {
name: 'virtualMachineDeployment'
params: {
adminUsername: adminUsername
adminPassword: adminPassword
imageReference: imageReference
name: name
nicConfigurations: [
{
ipConfigurations: [
{
name: 'ipconfig01'
subnetResourceId: subnetId
}
]
nicSuffix: '-nic-01'
}
]
osDisk: osDisk
osType: 'Windows'
vmSize: vmSize
zone: 0
location: location
}
}
Create Main File(Orchestrator)
In this step, you’ll reference your wrapper module, which serves as the orchestration entry point for deploying your code. This file can also be extended to include additional modules, such as networking, storage, and NSGs, and can incorporate logic and flags to support deployments across different regions and environments.
In this example, we’re keeping it simple:
- We create a resource group using a publicly available Azure Verified Module.
- Then, we call our local virtual machine wrapper module, which we've created earlier, to encapsulate and standardize VM deployments.
main.bicep
targetScope = 'subscription'
param subscriptionId string
param rgName string
param rgLocation string
param virtualMachines array = []
@description('Resource group for the application')
module resourceGroupForApplication 'br/public:avm/res/resources/resource-group:0.4.1' = {
name: 'resourceGroupDeployment'
scope: subscription(subscriptionId)
params: {
name: rgName
location: rgLocation
}
}
@description('Virtual Machine Module')
module virtualMachine './modules/virtualMachine/vm.bicep' = [
for vm in virtualMachines: {
scope: resourceGroup(rgName)
dependsOn: [
resourceGroupForApplication
]
name: take('virtualMachineDeployment-${vm.name}', 64)
params: {
name: vm.name
location: vm.location
adminUsername: vm.adminUsername
adminPassword: vm.adminPassword
subnetId: vm.subnetId
imageReference: vm.imageReference
osDisk: vm.osDisk
vmSize: vm.vmSize
}
}
]
Create Parameters file
Next, create a parameters file
This is where you'll define the input values specific to your environment. It allows you to keep your main.bicep clean and reusable, while injecting environment-specific settings like resource names, VM sizes, admin credentials, tags, and more.
For example, you might create a dev.parameters.json file that looks like this:
using 'main.bicep'
param subscriptionId = 'your-subscription-id'
param rgName = 'arg-syd-workload-example'
param rgLocation = 'australiaeast'
param virtualMachines = [
{
name: 'vm-dev-01'
location: 'australiaeast'
adminUsername: 'azureuser'
adminPassword: 'SuperSecurePassword123!'
subnetId: '/subscriptions/your-subscription-id/resourceGroups/arg-syd-workload-prod-network/providers/Microsoft.Network/virtualNetworks/vnt-syd-workload-prod-10.52.5.0_24/subnets/privateEndpoints'
imageReference: {
offer: 'WindowsServer'
publisher: 'MicrosoftWindowsServer'
sku: '2022-datacenter-azure-edition'
version: 'latest'
}
osDisk: {
caching: 'ReadWrite'
diskSizeGB: 256
managedDisk: {
storageAccountType: 'StandardSSD_LRS'
}
}
vmSize: 'Standard_D4s_v3'
}
]
Validation ( What If )
Before deploying your infrastructure, it's a good idea to validate what changes will be made. Use the what-if operation to preview the deployment and compare the desired state in your Bicep file.
This step is especially useful to:
- Avoid unintended changes
- Confirm resource creation, updates, or deletions
- Review parameter usage and outputs
In the below example, I've added additional properties that show more meaningful changes.
| Flag | Purpose | Best For |
|---|---|---|
-x Ignore NoChange Unsupported |
Hides noise from changes that don't matter or can't be evaluated | Cleaner output, easier to spot real changes |
-r FullResourcePayloads |
Shows full details of each resource before and after | Deep inspection, auditing, advanced debugging |
Example what-if
az deployment sub what-if --location australiaeast --template-file C:\repo\phipcode\phiptechblog\bicep\main.bicep --parameters C:\repo\phipcode\phiptechblog\bicep\main.dev.bicepparam -x Ignore NoChange Unsupported -r FullResourcePayloads
Note: The result may contain false positive predictions (noise).
You can help us improve the accuracy of the result by opening an issue here: https://aka.ms/WhatIfIssues
Resource and property changes are indicated with this symbol:
+ Create
The deployment will update the following scopes
Scope: /subscriptions/sub-id
+ resourceGroups/arg-syd-workload-example [2021-04-01]
apiVersion: "2021-04-01"
id: "/subscriptions/sub-id/resourceGroups/arg-syd-workload-example"
location: "australiaeast"
name: "arg-syd-workload-example"
type: "Microsoft.Resources/resourceGroups"
Scope: /subscriptions/sub-id/resourceGroups/arg-syd-workload-example
+ Microsoft.Compute/virtualMachines/vm-dev-01 [2024-07-01]
apiVersion: "2024-07-01"
id: "/subscriptions/sub-id/resourceGroups/arg-syd-workload-example/providers/Microsoft.Compute/virtualMachines/vm-dev-01"
location: "australiaeast"
name: "vm-dev-01"
properties.additionalCapabilities.hibernationEnabled: false
properties.additionalCapabilities.ultraSSDEnabled: false
properties.diagnosticsProfile.bootDiagnostics.enabled: false
properties.hardwareProfile.vmSize: "Standard_D4s_v3"
properties.networkProfile.networkInterfaces: [
0:
id: "/subscriptions/sub-id/resourceGroups/arg-syd-workload-example/providers/Microsoft.Network/networkInterfaces/vm-dev-01-nic-01"
properties.deleteOption: "Delete"
properties.primary: true
]
properties.osProfile.adminPassword: "*******"
properties.osProfile.adminUsername: "*******"
properties.osProfile.allowExtensionOperations: true
properties.osProfile.computerName: "vm-dev-01"
properties.osProfile.customData: "*******"
properties.osProfile.windowsConfiguration.enableAutomaticUpdates: true
properties.osProfile.windowsConfiguration.provisionVMAgent: true
properties.securityProfile.encryptionAtHost: true
properties.storageProfile.imageReference.offer: "WindowsServer"
properties.storageProfile.imageReference.publisher: "MicrosoftWindowsServer"
properties.storageProfile.imageReference.sku: "2022-datacenter-azure-edition"
properties.storageProfile.imageReference.version: "latest"
properties.storageProfile.osDisk.caching: "ReadWrite"
properties.storageProfile.osDisk.createOption: "FromImage"
properties.storageProfile.osDisk.deleteOption: "Delete"
properties.storageProfile.osDisk.diskSizeGB: 256
properties.storageProfile.osDisk.name: "vm-dev-01-disk-os-01"
properties.userData: "*******"
type: "Microsoft.Compute/virtualMachines"
+ Microsoft.Compute/virtualMachines/vm-dev-01/extensions/MicrosoftAntiMalware [2022-11-01]
apiVersion: "2022-11-01"
id: "/subscriptions/sub-id/resourceGroups/arg-syd-workload-example/providers/Microsoft.Compute/virtualMachines/vm-dev-01/extensions/MicrosoftAntiMalware"
location: "australiaeast"
name: "MicrosoftAntiMalware"
properties.autoUpgradeMinorVersion: true
properties.enableAutomaticUpgrade: false
properties.protectedSettings: "*******"
properties.publisher: "Microsoft.Azure.Security"
properties.settings.AntimalwareEnabled: "true"
properties.settings.RealtimeProtectionEnabled: "true"
properties.settings.ScheduledScanSettings.day: "7"
properties.settings.ScheduledScanSettings.isEnabled: "true"
properties.settings.ScheduledScanSettings.scanType: "Quick"
properties.settings.ScheduledScanSettings.time: "120"
properties.suppressFailures: false
properties.type: "IaaSAntimalware"
properties.typeHandlerVersion: "1.3"
type: "Microsoft.Compute/virtualMachines/extensions"
+ Microsoft.Network/networkInterfaces/vm-dev-01-nic-01 [2024-05-01]
apiVersion: "2024-05-01"
id: "/subscriptions/sub-id/resourceGroups/arg-syd-workload-example/providers/Microsoft.Network/networkInterfaces/vm-dev-01-nic-01"
location: "australiaeast"
name: "vm-dev-01-nic-01"
properties.auxiliaryMode: "None"
properties.auxiliarySku: "None"
properties.disableTcpStateTracking: false
properties.enableAcceleratedNetworking: true
properties.enableIPForwarding: false
properties.ipConfigurations: [
0:
name: "ipconfig01"
properties.subnet.id: "/subscriptions/sub-id/resourceGroups/arg-syd-workload-prod-network/providers/Microsoft.Network/virtualNetworks/vnt-syd-workload-prod-10.52.5.0_24/subnets/privateEndpoints"
]
type: "Microsoft.Network/networkInterfaces"
Deployment
Once you've validated your deployment with what-if, you're ready to launch your infrastructure using the .bicepparam file.
The .bicepparam format is a newer, cleaner way to define parameters for Bicep files.
Here’s how to deploy your Bicep template using a .bicepparam file:
az deployment sub create \
--location australiaeast \
--template-file main.bicep \
--parameters main.dev.bicepparam
Output
{
"id": "/subscriptions//providers/Microsoft.Resources/deployments/main",
"location": "australiaeast",
"name": "main",
"properties": {
"correlationId": "26e3450d-c577-46bc-95f2-384702fcb68a",
"debugSetting": null,
"dependencies": [
{
"dependsOn": [
{
"id": "/subscriptions//providers/Microsoft.Resources/deployments/resourceGroupDeployment",
"resourceName": "resourceGroupDeployment",
"resourceType": "Microsoft.Resources/deployments"
}
],
"id": "/subscriptions//resourceGroups/arg-syd-workload-example/providers/Microsoft.Resources/deployments/virtualMachineDeployment-vm-dev-01",
"resourceGroup": "arg-syd-workload-example",
"resourceName": "virtualMachineDeployment-vm-dev-01",
"resourceType": "Microsoft.Resources/deployments"
}
],
"duration": "PT2M4.9136544S",
"error": null,
"mode": "Incremental",
"onErrorDeployment": null,
"outputResources": [
{
"id": "/subscriptions//resourceGroups/arg-syd-workload-example"
},
{
"id": "/subscriptions//resourceGroups/arg-syd-workload-example/providers/Microsoft.Compute/virtualMachines/vm-dev-01",
"resourceGroup": "arg-syd-workload-example"
},
{
"id": "/subscriptions//resourceGroups/arg-syd-workload-example/providers/Microsoft.Compute/virtualMachines/vm-dev-01/extensions/MicrosoftAntiMalware",
"resourceGroup": "arg-syd-workload-example"
},
{
"id": "/subscriptions//resourceGroups/arg-syd-workload-example/providers/Microsoft.Network/networkInterfaces/vm-dev-01-nic-01",
"resourceGroup": "arg-syd-workload-example"
}
],
"outputs": null,
"parameters": {
"rgLocation": {
"type": "String",
"value": "australiaeast"
},
"rgName": {
"type": "String",
"value": "arg-syd-workload-example"
},
"subscriptionId": {
"type": "String",
"value": ""
},
"virtualMachines": {
"type": "Array",
"value": [
{
"adminPassword": "SuperSecurePassword123!",
"adminUsername": "azureuser",
"imageReference": {
"offer": "WindowsServer",
"publisher": "MicrosoftWindowsServer",
"sku": "2022-datacenter-azure-edition",
"version": "latest"
},
"location": "australiaeast",
"name": "vm-dev-01",
"osDisk": {
"caching": "ReadWrite",
"diskSizeGB": 256,
"managedDisk": {
"storageAccountType": "StandardSSD_LRS"
}
},
"subnetId": "/subscriptions//resourceGroups/arg-syd-workload-prod-network/providers/Microsoft.Network/virtualNetworks/vnt-syd-workload-prod-10.52.5.0_24/subnets/privateEndpoints",
"vmSize": "Standard_D4s_v3"
}
]
}
},
"parametersLink": null,
"providers": [
{
"id": null,
"namespace": "Microsoft.Resources",
"providerAuthorizationConsentState": null,
"registrationPolicy": null,
"registrationState": null,
"resourceTypes": [
{
"aliases": null,
"apiProfiles": null,
"apiVersions": null,
"capabilities": null,
"defaultApiVersion": null,
"locationMappings": null,
"locations": [
"australiaeast",
null
],
"properties": null,
"resourceType": "deployments",
"zoneMappings": null
}
]
}
],
"provisioningState": "Succeeded",
"templateHash": "11601139379235533124",
"templateLink": null,
"timestamp": "2025-05-23T14:53:59.196641+00:00",
"validatedResources": null
},
"tags": null,
"type": "Microsoft.Resources/deployments"
Final words.
By combining all the above, you get a clean, scalable, and maintainable approach to your code. You can spin up multiple environments with a single deployment. Thanks for reading.
Found this article useful? Why not buy Phi a coffee to show your appreciation?