Lab VMs on Azure — one ready-made Linux machine per student
Terraform that provisions the cohort’s lab fleet: one Ubuntu 24.04 VM per student
(plus one for the instructor), fully configured on first boot with everything
labs/lab00-setup.md asks for, the course repo cloned and the lab images pre-pulled.
Students receive an SSH line and a password; Lab 00 becomes “verify and go”.
┌───────────────── <prefix>-containers-lab-rg ─────────────────┐
laptop ── ssh ──┤ NSG (22, 80, 443, 8080, 30000-32767 from allowed CIDRs) │
│ vnet 10.60.0.0/16 / subnet vms 10.60.1.0/24 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ ana-s │ │ joao-m │ │ maria-p │ │ instructor │ │
│ │ D4s_v5 │ │ D4s_v5 │ │ D8s_v5 │ │ D8s_v5 │ │
│ │ pip + DNS │ │ pip + DNS │ │ pip + DNS │ │ pip + DNS │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ auto-shutdown 21:00 Lisbon · Premium SSD 64 GiB · cloud-init │
└───────────────────────────────────────────────────────────────┘
Why cloud-init (and not a provisioner or a bash script over SSH)
cloud-init (custom_data) |
remote-exec / SSH script |
Custom Script Extension | |
|---|---|---|---|
| Needs Terraform to reach the VM over SSH | no | yes (NSG, keys, retries) | no |
| Runs before anyone logs in, survives a Terraform crash | yes | no | yes |
| Logs | /var/log/cloud-init-output.log |
Terraform output only | extension logs, awkward |
| Re-runnable by hand | sudo /usr/local/sbin/lab-setup.sh |
re-apply with taint |
re-deploy extension |
| Works with 30 VMs in parallel | yes, the VM does the work | Terraform holds 30 SSH sessions | yes |
So: cloud-init.yaml carries a small #cloud-config (packages, a few config files)
and writes the real provisioning logic as a normal bash script
(/usr/local/sbin/lab-setup.sh) which it then runs once. You get the best of both —
first-boot automation with no inbound dependency, and a plain script you can read,
re-run or adapt. It deliberately mirrors labs/setup.sh (the students’ own install
path) so both stay in sync.
What the first boot does (≈ 6–9 minutes on a D4s_v5):
- Docker Engine 28+, buildx, compose from the official apt repo;
studentin thedockergroup; BuildKit + log rotation indaemon.json; inotify limits for kind. kubectl(pkgs.k8s.io channelkubectl_channel),kind(latest),helm,k9s,kubectx/kubens, bash completion,kalias.- Clones the course repo into
~/docker-kubernetes-training(variablecourse_repo_url), creates~/pinboard-labs. - Pre-pulls the images used in Labs 01–05 (
postgres:17-alpine,nginx:1.28-alpine,alpine,busybox,golang:1.25-alpine, distroless, metrics-server) and warms the kind node image by creating and deleting a throw-away cluster. - On the instructor VM only: pre-builds
pinboard-api:1.0/1.1/1.2-brokenandpinboard-web:1.0. - Writes
/var/lib/lab-setup/status=READY(orFAILED: <step>), shown in the MOTD at login.
Usage
cd infra/azure-lab-vms
az login && az account set --subscription <id>
cp terraform.tfvars.example terraform.tfvars # edit: prefix, students, your SSH public key
terraform init
terraform plan -out lab.tfplan
terraform apply lab.tfplan
terraform output vms # host names + ssh commands
column -s, -t < out/<prefix>-credentials.csv # one line per student (git-ignored)
./scripts/status.sh # READY / RUNNING: … / FAILED: … per VM
Then follow scripts/send-credentials.md to hand out access.
During the course
| Need | Command |
|---|---|
| Start every VM before a session (they auto-shutdown at 21:00) | ./scripts/start-all.sh |
| Stop paying between sessions | ./scripts/start-all.sh stop |
| Add a late student | add a key to students, terraform apply — only the new VM is created |
| A student broke their machine | terraform taint 'azurerm_linux_virtual_machine.vm["ana-s"]' && terraform apply → fresh VM, same DNS name, same password |
| Help a student | ssh student@<prefix>-<handle>.<region>.cloudapp.azure.com with your key |
| See why provisioning failed | ssh … 'sudo tail -50 /var/log/cloud-init-output.log' then sudo /usr/local/sbin/lab-setup.sh to retry |
| End of course | terraform destroy |
Opening the browser on a student’s Pinboard
Labs say http://localhost / http://localhost:8080. On a remote VM students have two options,
both documented in labs/lab00-setup.md (Step 01 now covers the VM case):
- SSH port-forward (works everywhere, nothing exposed):
ssh -L 8080:localhost:8080 -L 80:localhost:80 student@<host>then openhttp://localhost:8080. - Directly on the VM’s public name (the NSG allows 80/443/8080/NodePorts):
http://<prefix>-<handle>.<region>.cloudapp.azure.com:8080. Port 80 works once ingress-nginx is installed in Lab 07 (kind maps 80/443 to the VM). - VS Code Remote – SSH forwards ports automatically when it sees a listening socket.
Sizing and cost
| Item | Default | Note |
|---|---|---|
| VM | Standard_D4s_v5 (4 vCPU, 16 GiB) |
a 3-node kind cluster + builds + ingress + metrics-server stays under 8 GiB; 2 vCPU is noticeably slow on builds |
| Disk | Premium SSD 64 GiB | node image ≈ 1 GiB, build caches, many image tags |
| Region | westeurope |
change location; DNS suffix changes accordingly |
| Auto-shutdown | 21:00 Lisbon | auto_shutdown_time = "" to disable |
Rough West Europe list price (2026): D4s_v5 ≈ €0.19/h, P6 disk ≈ €10/month, public IP ≈ €3/month.
A 12-student cohort (13 VMs) running only during the 10 sessions plus homework
(say 40 h each) costs ≈ €100 of compute + ≈ €170 of disks/IPs if the fleet lives one month.
./scripts/start-all.sh stop between sessions is what keeps the compute part small.
Security notes
- Password login is enabled on purpose (students rarely have SSH keys ready); passwords are 16 random
characters, unique per VM, and the fleet lives only for the course. Prefer
allowed_source_cidrsover"*"when the class sits on one known network. - The instructor’s public key(s) are installed on every VM (
admin_ssh_public_keys). custom_datais inlifecycle.ignore_changesso editingcloud-init.yamlnever rebuilds running VMs; re-run the script by hand or taint the VM.- Nothing here touches your subscription beyond one resource group:
terraform destroyremoves it all. out/andterraform.tfvarsare git-ignored — they contain passwords.
Files
infra/azure-lab-vms/
├── versions.tf providers (azurerm ~> 4, random, local), optional azurerm backend
├── variables.tf prefix, students, sizes, NSG CIDRs, repo, auto-shutdown
├── main.tf RG, VNet/subnet/NSG, per-VM IP/NIC/VM (for_each), shutdown schedule, credentials CSV
├── outputs.tf vms (fqdn/ssh), passwords (sensitive), credentials_csv
├── cloud-init.yaml #cloud-config template → writes and runs /usr/local/sbin/lab-setup.sh
├── terraform.tfvars.example
└── scripts/ status.sh · start-all.sh · send-credentials.md