Infrastructure: the development VM
Provision a KVM guest on a private bridge with cloud-init, reach it via ProxyJump, and write the first ADR.
This is where the track starts being hands-on. By the end of this module you will have:
- A KVM virtual machine called
insurance-app-vm(16 vCPU, 32 GB RAM, 300 GB disk). - An IP on a private bridge so the VM is never accidentally exposed to the internet.
- SSH access via ProxyJump from your laptop, public-key only.
- An ADR that documents the access pattern for future-you and your teammates.
You’ll do all of this from your laptop, talking to a hypervisor host over SSH. The hypervisor can be a dl385-class server, a workstation with KVM extensions enabled, or any Linux box with libvirt and a working bridge interface — what matters is that you can run virt-install on it.
Why a dedicated VM (and not the laptop)
A laptop is great for editing code. It is bad for running the same workload your app will run in production. Three reasons:
- Networking. Your real environment isn’t going to be a NAT’d Wi-Fi network with a half-implemented host firewall. Get your service onto a real bridge with a real IP from day one.
- Reproducibility. “Works on my Mac” is the most expensive sentence in software. A VM you can rebuild from a script means every teammate has the same environment, every time.
- Long-running services. You’ll have a Liberty container, an ESB container, and later a database — all needing to stay up while you sleep. Your laptop’s lid should not be the kill switch.
The split we adopt: the laptop is your editor for a while, the VM is your runtime. In module 05 the laptop falls away entirely — source moves onto the VM and the laptop becomes a git mirror at best.
The host you’ll be talking to
You need a Linux host with:
- KVM and libvirt installed:
apt install qemu-kvm libvirt-daemon-system libvirt-clients virt-install cloud-image-utils. - A bridge interface that connects to your private network. On Ubuntu this is configured in
/etc/netplan/. We will call the bridgebr0throughout the track — substitute your own bridge name. - At least 32 GB of free RAM and 300 GB of free disk. If you’re squeezed, drop the VM to 8 vCPU / 16 GB / 100 GB; everything in the track still fits.
- Outbound internet access, so the VM can pull container images and Maven dependencies later.
If you do not have a host like that, three reasonable paths:
- Buy or repurpose a workstation; everything in this track was developed on a single dual-socket server.
- Use a cloud VM as the hypervisor. Bare-metal cloud is fine; nested-virt (KVM-on-KVM) works with a performance hit.
- Drop the bridge and use the default libvirt NAT network. You lose the “looks like production” property but keep the workflow.
The cloud-init recipe
We are not going to click through an Ubuntu installer. Cloud-init handles hostname, network, user, SSH key, and package updates. The first time you do this it feels excessive; by the third VM it is faster than reaching for the installer.
Two files go onto a small ISO that we attach to the VM as a CD-ROM.
user-data:
#cloud-config
hostname: insurance-app-vm
fqdn: insurance-app-vm
manage_etc_hosts: true
users:
- name: ze
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAA... your-laptop-public-key
ssh_pwauth: false
disable_root: true
package_update: true
growpart:
mode: auto
devices: ['/']
resize_rootfs: true
network-config:
version: 2
ethernets:
primary:
match:
name: "en*"
dhcp4: false
addresses:
- 10.10.20.10/24
routes:
- to: default
via: 10.10.20.1
nameservers:
addresses: [8.8.8.8, 1.1.1.1]
Substitute your own bridge’s subnet for 10.10.20.0/24. Build the seed ISO with cloud-localds:
sudo cloud-localds -N network-config \
/var/lib/libvirt/images/insurance-app-vm-seed.iso \
user-data
Provisioning the VM
Start from an Ubuntu 24.04 cloud image, copy it for this VM, and resize to the disk you want:
cd /var/lib/libvirt/images
sudo wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
sudo cp --reflink=auto noble-server-cloudimg-amd64.img insurance-app-vm.qcow2
sudo qemu-img resize insurance-app-vm.qcow2 300G
Then virt-install does the rest:
sudo virt-install \
--name insurance-app-vm \
--memory 32768 \
--vcpus 16 \
--cpu host-passthrough \
--os-variant ubuntu24.04 \
--disk path=/var/lib/libvirt/images/insurance-app-vm.qcow2,format=qcow2,bus=virtio \
--disk path=/var/lib/libvirt/images/insurance-app-vm-seed.iso,device=cdrom \
--network bridge=br0,model=virtio \
--graphics none --console pty,target_type=serial \
--import --noautoconsole
Roughly 60 seconds after this command, sudo virsh dominfo insurance-app-vm reports State: running and ping 10.10.20.10 succeeds from the hypervisor.
Reaching the VM: ProxyJump
The IP we gave the VM is on a private bridge. Your laptop is not on that bridge. Two options:
- VPN onto the bridge. Overkill for one VM, fine for a fleet.
- Use the hypervisor as a jump host. This is the right answer for development.
In ~/.ssh/config on your laptop:
Host insurance-app-vm
HostName 10.10.20.10
User ze
ProxyJump ze@your-hypervisor.example.com
Then ssh insurance-app-vm Just Works. Behind the scenes this is identical to running ssh -J ze@your-hypervisor.example.com ze@10.10.20.10 — the hypervisor opens a tunnel for the second hop, and your laptop’s key authenticates to both.
The first connection will prompt to accept the VM’s host key. Accept it; the laptop now has a permanent record.
Writing the first ADR
Architecture Decision Records are a habit worth forming on day one. The first ADR for this project documents what you just built: why ProxyJump over a VPN, what the IP / bridge / hostname are, when you would revisit (a second VM joining, the network changing).
A two-page ADR now saves four meetings later when someone asks “how do you SSH into this thing?”
A reasonable layout in your project:
docs/adr/
0001-ssh-access-to-insurance-app-vm.md
Status / Context / Decision / Consequences / Alternatives / Revisit-when. Keep each section short — ADRs are read in two minutes or not at all.
Common stumbles
- Host key conflicts after rebuilds. If you
virsh destroyand re-provision, your laptop’s~/.ssh/known_hostsstill has the old key. Delete the offending line or runssh-keygen -R 10.10.20.10. - The seed ISO not being read. Cloud-init only runs once per VM instance-id. If you change the seed and re-attach, run
sudo cloud-init cleaninside the VM before rebooting. - Bridges with no IP on the host. A bridge interface without an address is a switch with no uplink. Make sure the bridge has an IP on the host so the host can reach the VM (and vice versa) for the ProxyJump.
What you have
- A reproducible recipe for building the VM (cloud-init + virt-install).
- An IP on a private bridge.
- A ProxyJump pattern that survives laptop reinstalls.
- One ADR.
Module 02 installs the toolchain inside this VM.
Next: 02 — The toolchain →