
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/24for 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:
- The NSG has no dependencies, so it goes first.
- The VNet references
nsg.idin its subnet, so it waits for the NSG. - The public IP shares nothing with either, so it deploys alongside them.
- The NIC references both the subnet and the public IP, so it waits for both.
- 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.
Ready to Master Cloud Engineering?
Get access to hands-on labs, expert-led courses, and a supportive community.
Practice it hands-on
Labs where you can apply what this article covers, in a real environment.
Deploy a Virtual Network and Virtual Machine Using Bicep
Build a real-world Bicep template that deploys a VNet with subnets, NSG rules, a public IP, and a Linux VM with SSH access.
cloudlearn.ioStart labDeploy a Full Azure Environment Using Bicep Infrastructure as Code
Write modular Bicep templates to deploy a VNet, App Service, SQL Database, and Key Vault, then deploy the full environment using Azure CLI.
cloudlearn.ioStart labIntroduction to Azure Bicep - Write and Deploy Your First Template
Learn Azure Bicep syntax by writing your first template to deploy a storage account with parameters, variables, and outputs using Azure CLI.
cloudlearn.ioStart lab




