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

  1. Deploy a VM using an ARM template with parameters
  2. Add a data disk and verify it
  3. Configure a VM Scale Set with autoscale
  4. Deploy an App Service with a staging slot and perform a slot swap
  5. 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 omit dependsOn, 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 + mount to 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-count and --max-count define 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:

  1. In the portal, open the VNet vnet-lab03Subnets+ Gateway Subnet — NO, for Bastion you add: + Subnet → Name it exactly AzureBastionSubnet → Address range /26 (minimum)
  2. Search for Bastion in the portal → Create → Select vnet-lab03 and the AzureBastionSubnet
  3. Create a new Standard Public IP for Bastion
  4. After deployment (takes ~5 min), go to vm-lab03ConnectBastion → 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

TrapEffectFix
Missing dependsOn in ARM templateResources deploy in wrong order, causing failuresExplicitly declare dependencies or use reference() function (implicit dependency)
Attaching disk without formatting inside OSDisk visible in Azure but unusable in VMSSH in, run fdisk/mkfs/mount or use cloud-init
Autoscale with no scale-in ruleScale set grows but never shrinksAlways add both scale-out and scale-in rules
Slot swap with non-sticky app settingsProduction points to staging's database/configMark environment-specific settings as slot settings
AzureBastionSubnet name typoBastion deployment validation failsCopy-paste the name exactly — case-sensitive

Lab Takeaways

  1. what-if before deploying is a critical exam skill — it previews changes without risk.
  2. ARM outputs ("outputs" section) let you capture resource properties (IPs, IDs) for post-deployment use.
  3. Data disks are Azure resources but also need OS-level setup — attaching in Azure and mounting in the OS are separate steps.
  4. Autoscale rules come in pairs — scale-out trigger and scale-in trigger. One without the other creates an unbalanced system.
  5. Deployment slots are the cleanest zero-downtime deployment strategy for App Service.