Victor patches a VM, mounts a data disk, schedules a shutdown, then builds the runbook that starts it back up.
Every chore App Service does for you, here you do yourself
A virtual machine is the bottom rung of the compute spectrum: Azure owns the hardware and the hypervisor, and everything above that line — the OS, its patches, its disks, its uptime schedule — is yours. This lab runs the chores in order on the cheapest VM that still teaches them: patch the OS with a run-command (no SSH session required), attach a data disk that survives a VM rebuild because it is a separate resource, and schedule the machine off when nobody needs it. The last step is the one the exam — and production — actually tests: auto-shutdown is a one-way door. Azure will stop your VM on a schedule. It will not start it again. You have to build that half yourself.
Shutdown is automatic. Start is not.
You need a free Azure account and the Azure CLI (az), signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs, and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash), preinstalled and already signed in — this lab is written for it, since every step runs over the CLI with no separate SSH session.
A B1s VM is a few tenths of a cent per hour, the data disk a few cents a month prorated to minutes, and the Automation account's Free tier covers this lab's job minutes at $0. Run start-to-finish and tear down the same session and the total is a few cents. The teardown section removes everything. Pairs with Class 11d — Virtual Machines.
A small Linux VM
Create a burstable B-series VM — cheap, and the right shape for a bursty, mostly-idle workload. SSH keys are generated for you if you do not already have a pair.
# Windows/Git Bash: stop it mangling resource-id arguments (harmless on macOS/Linux)
export MSYS_NO_PATHCONV=1
SUFFIX=$RANDOM
RG="campux-lab-vm-rg"
LOCATION="eastus"
VM="campux-vm-$SUFFIX"
az group create --name "$RG" --location "$LOCATION" -o table
az vm create --resource-group "$RG" --name "$VM" \
--image Ubuntu2404 --size Standard_B1s \
--admin-username azureuser --generate-ssh-keys \
-o table
az vm show --resource-group "$RG" --name "$VM" --query "provisioningState" -o tsv reads Succeeded.Patch it — no SSH session needed
az vm run-command executes a script on the VM through the Azure agent, over the control plane, whether or not port 22 is even open. That is what patching at scale actually looks like — nobody signs in by hand to a hundred machines.
az vm run-command invoke --resource-group "$RG" --name "$VM" \ --command-id RunShellScript \ --scripts "sudo apt-get update && sudo apt-get -y upgrade" \ --query "value[0].message" -o tsv
[stdout] ... and no [stderr] failure. The command's own exit path — not a login you performed — is what patched the machine.A data disk that outlives the machine
Attach a managed data disk and mount it — using the stable Azure device symlink rather than a raw device name, since /dev/sdc can shift if disks are added or removed. This disk is a separate Azure resource: it survives a VM delete-and-recreate that the OS disk would not.
az vm disk attach --resource-group "$RG" --vm-name "$VM" \
--name "$VM-data" --new --size-gb 32 --sku StandardSSD_LRS -o table
az vm run-command invoke --resource-group "$RG" --name "$VM" \
--command-id RunShellScript --scripts '
DISK=/dev/disk/azure/scsi1/lun0
sudo parted $DISK --script mklabel gpt mkpart xfspart xfs 0% 100%
sudo partprobe $DISK
sudo mkfs.xfs ${DISK}-part1
sudo mkdir -p /data
sudo mount ${DISK}-part1 /data
echo "campux data disk mounted at $(date -u)" | sudo tee /data/proof.txt
UUID=$(sudo blkid -s UUID -o value ${DISK}-part1)
echo "UUID=$UUID /data xfs defaults,nofail 1 2" | sudo tee -a /etc/fstab
cat /data/proof.txt
' --query "value[0].message" -o tsv
campux data disk mounted at .... The nofail option in the /etc/fstab line means a future boot still succeeds even if the disk were ever missing — the standard safeguard against an unbootable VM.Schedule it off
Add an auto-shutdown schedule so the VM stops billing outside its working hours. The time is UTC only — there is no time-zone offset on the flag itself, only a separate notification setting.
# replace with an address you can actually check NOTIFY_EMAIL="[email protected]" az vm auto-shutdown --resource-group "$RG" --name "$VM" \ --time 1900 --email "$NOTIFY_EMAIL"
az resource show --resource-group "$RG" --name shutdown-computevm-$VM --resource-type Microsoft.DevTestLab/schedules --query "properties.status" -o tsv reads Enabled.You just configured Azure to stop this VM every day at 19:00 UTC. Look for the setting that brings it back at, say, 07:00 the next morning. There is not one. az vm auto-shutdown has no auto-start counterpart, no flag, no portal toggle — the feature stops at "stop." A VM scheduled off this way stays off until something else explicitly starts it. Ship only this half of the schedule and you have built an outage with a start time on it, not a cost saving.
Pair it: an Automation runbook that starts the VM
Class 11d names the fix directly: pair auto-shutdown with a scheduler — an Automation runbook or a Logic App — that starts the VM before it is needed. Here you build the runbook half: an Automation account with its default system-assigned identity, granted just enough rights to start this one VM, running a PowerShell script on its own schedule.
AA="campux-vm-aa-$SUFFIX" RUNBOOK="start-campux-vm" az automation account create --name "$AA" --resource-group "$RG" \ --location "$LOCATION" --sku Free -o table # grant the account's own system-assigned identity just enough to start this VM PRINCIPAL_ID=$(az automation account show --name "$AA" --resource-group "$RG" \ --query "identity.principalId" -o tsv) RG_ID=$(az group show --name "$RG" --query id -o tsv) az role assignment create --assignee-object-id "$PRINCIPAL_ID" \ --assignee-principal-type ServicePrincipal \ --role "Virtual Machine Contributor" --scope "$RG_ID" # the runbook: sign in as the account's own identity, then start the VM cat > start-vm.ps1 <<'EOF' param( [string]$ResourceGroupName, [string]$VMName ) Connect-AzAccount -Identity | Out-Null Start-AzVM -ResourceGroupName $ResourceGroupName -Name $VMName Write-Output "Started $VMName in $ResourceGroupName" EOF az automation runbook create --automation-account-name "$AA" --resource-group "$RG" \ --name "$RUNBOOK" --type PowerShell -o table az automation runbook replace-content --automation-account-name "$AA" --resource-group "$RG" \ --name "$RUNBOOK" --content @start-vm.ps1 az automation runbook publish --automation-account-name "$AA" --resource-group "$RG" \ --name "$RUNBOOK"
az automation runbook show --automation-account-name "$AA" --resource-group "$RG" --name "$RUNBOOK" --query "state" -o tsv reads Published.identity.principalId comes back emptyNew Automation accounts enable a system-assigned identity by default, but if this one did not, turn it on explicitly: az resource update --ids $(az automation account show --name "$AA" --resource-group "$RG" --query id -o tsv) --set identity.type=SystemAssigned, then re-run the account show query above.
Now schedule it — a daily 07:00 UTC start to answer the 19:00 UTC shutdown — and link the schedule to the runbook. The Azure CLI has no dedicated command for that last link yet, so this one step goes through az rest against the same Automation REST API the CLI itself calls.
SCHEDULE="start-campux-vm-daily"
START_TIME=$(date -u -d "tomorrow 07:00" +%Y-%m-%dT%H:%M:%S+00:00)
az automation schedule create --automation-account-name "$AA" --resource-group "$RG" \
--name "$SCHEDULE" --frequency Day --interval 1 \
--start-time "$START_TIME" --time-zone UTC -o table
SUB_ID=$(az account show --query id -o tsv)
JOB_SCHEDULE_ID=$(cat /proc/sys/kernel/random/uuid)
az rest --method put \
--uri "https://management.azure.com/subscriptions/$SUB_ID/resourceGroups/$RG/providers/Microsoft.Automation/automationAccounts/$AA/jobSchedules/$JOB_SCHEDULE_ID?api-version=2024-10-23" \
--body "{\"properties\":{\"runbook\":{\"name\":\"$RUNBOOK\"},\"schedule\":{\"name\":\"$SCHEDULE\"},\"parameters\":{\"ResourceGroupName\":\"$RG\",\"VMName\":\"$VM\"}}}"
az rest call returns the new job schedule with "runbook":{"name":"start-campux-vm"} and "schedule":{"name":"start-campux-vm-daily"} — the pairing is wired.Stop the VM, then run the runbook once by hand to prove the mechanism works today, rather than waiting until tomorrow's 07:00 UTC to find out.
az vm deallocate --resource-group "$RG" --name "$VM" -o none
az automation runbook start --automation-account-name "$AA" --resource-group "$RG" \
--name "$RUNBOOK" \
--parameters ResourceGroupName="$RG" VMName="$VM" -o table
# poll until the job finishes (usually under two minutes for a small VM)
az automation job list --automation-account-name "$AA" --resource-group "$RG" \
--query "[0].{status:status,runbook:runbookName}" -o table
status reads Completed, and az vm get-instance-view --resource-group "$RG" --name "$VM" --query "instanceView.statuses[1].displayStatus" -o tsv reads VM running. A schedule you have never watched run is exactly as trustworthy as a backup you have never restored — you just watched this one work.Tear it down
The Automation account, its runbook, its schedule, the VM, both disks, and the auto-shutdown schedule all live in the same resource group. One delete clears all of it.
az group delete --name "$RG" --yes
az group exists --name "$RG" # -> false
az group exists returns false.What you can now honestly claim
You created a Linux VM, patched it without an SSH session, attached and mounted a data disk using the stable Azure disk path and a persistence-safe fstab entry, and scheduled the VM off. Then you found the trap the exam and production both set: auto-shutdown has no auto-start, so you built an Automation account with its own identity, scoped it to just this VM, wrote and published a runbook, scheduled it, wired the schedule to the runbook, and proved the whole loop by running it once yourself. You can now tell a stopped-forever VM from a scheduled one apart on sight — the second one has a start job somewhere, and you can name where.