Lab 03: Virtual Machines, VMSS, and ARM Templates
Estimated time: 60–75 minutes Difficulty: ⭐⭐⭐☆☆ Environment: Azure free account — portal + Azure CLI
Prerequisites
- Azure CLI ≥ 2.50 with
az login - A subscription with available quota for B-series VMs
az account show --query "{name:name, id:id}" -o table
Lab Objectives
- Deploy a VM using an ARM template with parameters
- Add a data disk and verify it
- Configure a VM Scale Set with autoscale
- Deploy an App Service with a staging slot and perform a slot swap
- Create an Azure Bastion host and test browser-based SSH
Step 1: Create the Resource Group
RG="rg-az104-lab03"
LOCATION="eastus"
az group create --name $RG --location $LOCATION
Step 2: Deploy a VM via ARM Template
Create the ARM template file:
cat > vm-template.json << 'EOF'
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"vmName": {
"type": "string",
"defaultValue": "vm-lab03",
"metadata": { "description": "Name of the virtual machine" }
},
"adminUsername": {
"type": "string",
"defaultValue": "azureuser"
},
"adminPassword": {
"type": "securestring"
},
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]"
}
},
"variables": {
"vnetName": "vnet-lab03",
"subnetName": "default",
"nicName": "[concat(parameters('vmName'), '-nic')]",
"osDiskName": "[concat(parameters('vmName'), '-osdisk')]"
},
"resources": [
{
"type": "Microsoft.Network/virtualNetworks",
"apiVersion": "2023-05-01",
"name": "[variables('vnetName')]",
"location": "[parameters('location')]",
"properties": {
"addressSpace": { "addressPrefixes": ["10.0.0.0/16"] },
"subnets": [{
"name": "[variables('subnetName')]",
"properties": { "addressPrefix": "10.0.1.0/24" }
}]
}
},
{
"type": "Microsoft.Network/networkInterfaces",
"apiVersion": "2023-05-01",
"name": "[variables('nicName')]",
"location": "[parameters('location')]",
"dependsOn": ["[variables('vnetName')]"],
"properties": {
"ipConfigurations": [{
"name": "ipconfig1",
"properties": {
"privateIPAllocationMethod": "Dynamic",
"subnet": {
"id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('vnetName'), variables('subnetName'))]"
}
}
}]
}
},
{
"type": "Microsoft.Compute/virtualMachines",
"apiVersion": "2023-03-01",
"name": "[parameters('vmName')]",
"location": "[parameters('location')]",
"dependsOn": ["[variables('nicName')]"],
"properties": {
"hardwareProfile": { "vmSize": "Standard_B2s" },
"storageProfile": {
"imageReference": {
"publisher": "Canonical",
"offer": "0001-com-ubuntu-server-jammy",
"sku": "22_04-lts-gen2",
"version": "latest"
},
"osDisk": {
"name": "[variables('osDiskName')]",
"createOption": "FromImage",
"managedDisk": { "storageAccountType": "Standard_SSD_LRS" }
}
},
"osProfile": {
"computerName": "[parameters('vmName')]",
"adminUsername": "[parameters('adminUsername')]",
"adminPassword": "[parameters('adminPassword')]"
},
"networkProfile": {
"networkInterfaces": [{
"id": "[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]"
}]
}
}
}
],
"outputs": {
"vmId": {
"type": "string",
"value": "[resourceId('Microsoft.Compute/virtualMachines', parameters('vmName'))]"
},
"privateIp": {
"type": "string",
"value": "[reference(variables('nicName')).ipConfigurations[0].properties.privateIPAddress]"
}
}
}
EOF
Run what-if first (preview changes without deploying):
az deployment group what-if \
--resource-group $RG \
--template-file vm-template.json \
--parameters adminPassword="P@ssw0rd!Azure104"
Review the output — it shows every resource that will be created, modified, or deleted.
Deploy the template:
az deployment group create \
--resource-group $RG \
--template-file vm-template.json \
--parameters adminPassword="P@ssw0rd!Azure104" \
--query "properties.outputs" \
-o table
⚠️ Tricky spot: The ARM template uses
"dependsOn"to control deployment order. The NIC depends on the VNet (needs the subnet ID), and the VM depends on the NIC. If you omitdependsOn, resources may try to deploy in parallel before their dependencies exist.
Step 3: Add a Data Disk to the VM
# Create a managed disk
az disk create \
--resource-group $RG \
--name "vm-lab03-datadisk01" \
--size-gb 32 \
--sku Standard_SSD_LRS \
--location $LOCATION
# Attach the disk to the VM
az vm disk attach \
--resource-group $RG \
--vm-name "vm-lab03" \
--name "vm-lab03-datadisk01"
# Verify the disk is attached
az vm show \
--resource-group $RG \
--name "vm-lab03" \
--query "storageProfile.dataDisks[].{name:name, lun:lun, sizeGb:diskSizeGb}" \
-o table
⚠️ Tricky spot: Attaching a disk does NOT format or mount it inside the OS. You still need to SSH in and run
fdisk+mkfs+mountto use the disk. In the exam, "attach" and "format/mount" are separate concepts.
Step 4: Configure a VM Scale Set with Autoscale
# Create a VMSS in Flexible orchestration mode
az vmss create \
--resource-group $RG \
--name "vmss-lab03" \
--image Ubuntu2204 \
--admin-username azureuser \
--admin-password "P@ssw0rd!Azure104" \
--instance-count 2 \
--vm-sku Standard_B2s \
--orchestration-mode Flexible \
--platform-fault-domain-count 1 \
--location $LOCATION
# Add autoscale profile
VMSS_ID=$(az vmss show \
--resource-group $RG \
--name "vmss-lab03" \
--query id -o tsv)
az monitor autoscale create \
--resource $VMSS_ID \
--resource-group $RG \
--name "autoscale-lab03" \
--min-count 2 \
--max-count 5 \
--count 2
# Add scale-out rule: scale out when CPU > 70% for 5 minutes
az monitor autoscale rule create \
--resource-group $RG \
--autoscale-name "autoscale-lab03" \
--condition "Percentage CPU > 70 avg 5m" \
--scale out 1
# Add scale-in rule: scale in when CPU < 30% for 5 minutes
az monitor autoscale rule create \
--resource-group $RG \
--autoscale-name "autoscale-lab03" \
--condition "Percentage CPU < 30 avg 5m" \
--scale in 1
# Verify
az monitor autoscale show \
--resource-group $RG \
--name "autoscale-lab03" \
--query "{min:profiles[0].capacity.minimum, max:profiles[0].capacity.maximum, default:profiles[0].capacity.default}" \
-o table
⚠️ Tricky spot:
--min-countand--max-countdefine the bounds for autoscale, not actual instance counts. The current instance count is--count. Autoscale will never go below min or above max.
Step 5: Deploy an App Service with Deployment Slots
# Create App Service Plan (Standard required for slots)
az appservice plan create \
--name "plan-lab03" \
--resource-group $RG \
--sku S1 \
--is-linux
# Create the web app
APP_NAME="webapp-lab03-$(openssl rand -hex 3)"
az webapp create \
--resource-group $RG \
--plan "plan-lab03" \
--name $APP_NAME \
--runtime "NODE:20-lts"
echo "App URL: https://$APP_NAME.azurewebsites.net"
# Create a staging deployment slot
az webapp deployment slot create \
--name $APP_NAME \
--resource-group $RG \
--slot staging
echo "Staging URL: https://$APP_NAME-staging.azurewebsites.net"
# Deploy a simple HTML app to staging
mkdir -p /tmp/webapp && cat > /tmp/webapp/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html><body><h1>STAGING SLOT — Build v2</h1></body></html>
HTMLEOF
cd /tmp && zip -j webapp.zip webapp/index.html
az webapp deployment source config-zip \
--resource-group $RG \
--name $APP_NAME \
--slot staging \
--src /tmp/webapp.zip
# Test staging before swap
curl -s "https://$APP_NAME-staging.azurewebsites.net" | grep "STAGING"
Perform the slot swap:
az webapp deployment slot swap \
--resource-group $RG \
--name $APP_NAME \
--slot staging \
--target-slot production
# Verify production now shows the staging content
curl -s "https://$APP_NAME.azurewebsites.net" | grep "STAGING"
⚠️ Tricky spot: Slot swap is near-instant but app settings (unless marked sticky) swap too. If your production slot uses a different database connection string than staging, you must mark that setting as a slot setting (sticky) or the swap will point production at the staging database.
Step 6: Azure Bastion (Bonus — Portal Steps)
Bastion requires a Standard Public IP and a dedicated subnet — easier to do via portal for this lab:
- In the portal, open the VNet
vnet-lab03→ Subnets → + Gateway Subnet — NO, for Bastion you add: + Subnet → Name it exactlyAzureBastionSubnet→ Address range/26(minimum) - Search for Bastion in the portal → Create → Select
vnet-lab03and theAzureBastionSubnet - Create a new Standard Public IP for Bastion
- After deployment (takes ~5 min), go to
vm-lab03→ Connect → Bastion → enter credentials → browser-based SSH opens
⚠️ Tricky spot: The subnet name must be
AzureBastionSubnet— spelled exactly, case-sensitive. Any deviation causes deployment validation to fail immediately.
Step 7: Clean Up
az group delete --name $RG --yes --no-wait
rm -f vm-template.json /tmp/webapp.zip
rm -rf /tmp/webapp
Lab Tricky Spots Summary
| Trap | Effect | Fix |
|---|---|---|
Missing dependsOn in ARM template | Resources deploy in wrong order, causing failures | Explicitly declare dependencies or use reference() function (implicit dependency) |
| Attaching disk without formatting inside OS | Disk visible in Azure but unusable in VM | SSH in, run fdisk/mkfs/mount or use cloud-init |
| Autoscale with no scale-in rule | Scale set grows but never shrinks | Always add both scale-out and scale-in rules |
| Slot swap with non-sticky app settings | Production points to staging's database/config | Mark environment-specific settings as slot settings |
AzureBastionSubnet name typo | Bastion deployment validation fails | Copy-paste the name exactly — case-sensitive |
Lab Takeaways
what-ifbefore deploying is a critical exam skill — it previews changes without risk.- ARM outputs (
"outputs"section) let you capture resource properties (IPs, IDs) for post-deployment use. - Data disks are Azure resources but also need OS-level setup — attaching in Azure and mounting in the OS are separate steps.
- Autoscale rules come in pairs — scale-out trigger and scale-in trigger. One without the other creates an unbalanced system.
- Deployment slots are the cleanest zero-downtime deployment strategy for App Service.