Spinning Up a KVM VM from the CLI
The first KVM VM anyone creates is usually through a GUI, virt-manager, Cockpit, or a Proxmox web interface, clicking through disk size, network, and ISO selection. That's a fine way to learn what the options mean. But once you're creating VMs regularly, the GUI becomes the slow part: it's a sequence of clicks that has to happen the same way every time, and "the same way every time" is exactly what a command line is good at.
The two pieces: a base image and cloud-init
Rather than booting an installer ISO and clicking through an OS install, I start from a cloud image, the same minimal, pre-installed images cloud providers use, and let cloud-init handle the first-boot configuration:
# Download a cloud image once
wget https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img \
-O /var/lib/libvirt/images/ubuntu-24.04-base.img
Cloud-init configuration is two small YAML files. user-data sets up the user, SSH key, and any first-boot commands:
# user-data
#cloud-config
hostname: app-01
users:
- name: deploy
sudo: ALL=(ALL) NOPASSWD:ALL
groups: sudo
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAA... your-key-here
package_update: true
packages:
- curl
- git
meta-data just needs an instance ID and hostname:
# meta-data
instance-id: app-01
local-hostname: app-01
These get packaged into a small ISO that cloud-init reads on first boot:
genisoimage -output app-01-seed.iso -volid cidata -joliet -rock user-data meta-data
Creating the VM
With the base image copied and the seed ISO built, virt-install creates and starts the VM in one command:
cp /var/lib/libvirt/images/ubuntu-24.04-base.img /var/lib/libvirt/images/app-01.qcow2
qemu-img resize /var/lib/libvirt/images/app-01.qcow2 40G
virt-install \
--name app-01 \
--memory 4096 \
--vcpus 2 \
--disk /var/lib/libvirt/images/app-01.qcow2,format=qcow2,bus=virtio \
--disk app-01-seed.iso,device=cdrom \
--os-variant ubuntu24.04 \
--network bridge=br0,model=virtio \
--import \
--noautoconsole
--import tells virt-install to boot the existing disk image directly rather than running an installer, and --noautoconsole returns control to the terminal instead of opening a console window. Within a minute or two, the VM is up, cloud-init has created the deploy user with the SSH key from user-data, and:
ssh deploy@<vm-ip>
just works, no manual OS install, no clicking through a wizard, no remembering to add the SSH key afterward.
Why this is worth doing even for one-off VMs
The obvious benefit is that this is scriptable, wrap it in a shell function or an Ansible task and a new VM is a single command with a hostname as the argument. But even for a genuinely one-off VM, there's a quieter benefit: the entire configuration, hostname, user, packages, SSH key, is sitting in two small text files that can go into the same git repo as everything else. Six months later, if you need to recreate that VM or just remember what was installed on it, the answer isn't "whatever I remember clicking," it's two files you can read in thirty seconds.