Deploy a VNet and VM with Azure Bicep: Complete Walkthrough
Azure·September 15, 2026·5 min read

Deploy a VNet and VM with Azure Bicep: Complete Walkthrough

Deploying through the portal works fine until you have to do it again. Then again, slightly differently, for staging. Then production needs the same thing in another region.

At that point clicking becomes a liability. It is slow, it is easy to get subtly wrong, and there is no way to review it in a pull request. This walks through the same deployment expressed as a single Bicep template: a virtual network, a VM inside it, and everything in between.

What you are building

  • A virtual network on 10.0.0.0/16
  • A subnet at 10.0.1.0/24 for the VM
  • A network security group allowing SSH inbound
  • A public IP address
  • A network interface joining the VM to the subnet
  • An Ubuntu VM using SSH key authentication

All of it in one file, created by one command.

Before you start

You need the Azure CLI with Bicep support, a subscription, and enough familiarity with Azure networking that VNet, subnet and NSG are not new words.

If they are new words, build the network by hand once first. Create and configure a virtual network and subnets covers the concepts this template assumes, and the template makes far more sense afterwards.

The template

The whole thing, then a walk through the interesting parts.

@description('Azure region for all resources')
param location string = resourceGroup().location

@description('Admin username for the VM')
param adminUsername string = 'azureuser'

@description('SSH public key for authentication')
@secure()
param sshPublicKey string

@description('CIDR allowed to reach SSH. Narrow this before using anywhere real.')
param sshSourcePrefix string = '*'

var vnetName = 'lab-vnet'
var subnetName = 'default'
var nsgName = 'lab-nsg'
var vmName = 'lab-vm'
var pipName = 'lab-pip'
var nicName = 'lab-nic'

// Network Security Group
resource nsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = {
  name: nsgName
  location: location
  properties: {
    securityRules: [
      {
        name: 'AllowSSH'
        properties: {
          priority: 1000
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '22'
          sourceAddressPrefix: sshSourcePrefix
          destinationAddressPrefix: '*'
        }
      }
    ]
  }
}

// Virtual Network
resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: { addressPrefixes: ['10.0.0.0/16'] }
    subnets: [
      {
        name: subnetName
        properties: {
          addressPrefix: '10.0.1.0/24'
          networkSecurityGroup: { id: nsg.id }
        }
      }
    ]
  }
}

// Public IP
resource pip 'Microsoft.Network/publicIPAddresses@2023-05-01' = {
  name: pipName
  location: location
  sku: { name: 'Standard' }
  properties: {
    publicIPAllocationMethod: 'Static'
  }
}

// Network Interface
resource nic 'Microsoft.Network/networkInterfaces@2023-05-01' = {
  name: nicName
  location: location
  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          subnet: { id: vnet.properties.subnets[0].id }
          publicIPAddress: { id: pip.id }
          privateIPAllocationMethod: 'Dynamic'
        }
      }
    ]
  }
}

// Virtual Machine
resource vm 'Microsoft.Compute/virtualMachines@2023-07-01' = {
  name: vmName
  location: location
  properties: {
    hardwareProfile: { vmSize: 'Standard_B1s' }
    osProfile: {
      computerName: vmName
      adminUsername: adminUsername
      linuxConfiguration: {
        disablePasswordAuthentication: true
        ssh: {
          publicKeys: [
            {
              path: '/home/${adminUsername}/.ssh/authorized_keys'
              keyData: sshPublicKey
            }
          ]
        }
      }
    }
    storageProfile: {
      imageReference: {
        publisher: 'Canonical'
        offer: '0001-com-ubuntu-server-jammy'
        sku: '22_04-lts-gen2'
        version: 'latest'
      }
      osDisk: {
        createOption: 'FromImage'
        managedDisk: { storageAccountType: 'Standard_LRS' }
      }
    }
    networkProfile: {
      networkInterfaces: [{ id: nic.id }]
    }
  }
}

output vmPublicIP string = pip.properties.ipAddress
output sshCommand string = 'ssh ${adminUsername}@${pip.properties.ipAddress}'

That SSH rule is a lab default, not a production one

sshSourcePrefix defaults to *, which means the whole internet can reach port 22. That is acceptable in a throwaway lab resource group and nowhere else. Pass your own address when you deploy, or better, remove the public IP entirely and reach the VM through Bastion.

How the ordering works

There is no dependsOn anywhere in that file, and there does not need to be. Bicep reads the references and builds the graph:

  1. The NSG has no dependencies, so it goes first.
  2. The VNet references nsg.id in its subnet, so it waits for the NSG.
  3. The public IP shares nothing with either, so it deploys alongside them.
  4. The NIC references both the subnet and the public IP, so it waits for both.
  5. The VM references nic.id, so it goes last.

Add a resource that references an existing one and the ordering updates by itself. This is the single largest quality of life improvement over hand maintained ARM dependency arrays, where a missing entry produced a deployment that failed intermittently depending on timing.

If you are reaching for dependsOn, look again

Explicit dependencies are almost never necessary in Bicep. When you feel the need for one, there is usually a property reference that expresses the same relationship, and expressing it that way means it cannot fall out of date.

Deploy it

az group create --name bicep-lab-rg --location canadacentral

az deployment group create \
  --resource-group bicep-lab-rg \
  --template-file main.bicep \
  --parameters sshPublicKey="$(cat ~/.ssh/id_rsa.pub)"

It takes a couple of minutes. When it finishes, the outputs hand you the public IP and a ready made SSH command.

Preview first

Get into the habit of running what-if before any deployment that touches something you care about:

az deployment group what-if \
  --resource-group bicep-lab-rg \
  --template-file main.bicep \
  --parameters sshPublicKey="$(cat ~/.ssh/id_rsa.pub)"

It reports exactly what would be created, changed or removed and changes nothing. If the output surprises you, the template is wrong, and finding that out here costs nothing.

Run it yourself

The same deployment runs as a guided lab with checks at each stage, which is worth doing even if the template above deployed cleanly for you, because the checks catch the things that look fine and are not. Deploy a virtual network and VM using Bicep.

Clean up

az group delete --name bicep-lab-rg --yes --no-wait

Everything went into one resource group, so this removes the VM, NIC, public IP, network and NSG together.

Where to go next

This template is static. Real ones vary by environment, which means conditions and loops. Conditions, loops and what-if deployments covers the patterns.

After that, the lifecycle question. Deleting a resource from this template and redeploying leaves it running in Azure, and deployment stacks are how you close that gap.

For the reasoning behind Bicep and how it relates to ARM, read Azure Bicep explained.