2.4 · Getting a guest running

virt-install Explained: Every Flag for an arm64 Guest

libvirt · KVM · QEMU · Raspberry Pi

The previous article cleared the four failures that block a first launch; this one explains the command itself.

Here is the command that builds a Debian arm64 guest on a Raspberry Pi 5:

virt-install \
  --connect qemu:///system \
  --name lab1 \
  --arch aarch64 --machine virt \
  --ram 2048 --vcpus 2 \
  --disk path=/var/lib/libvirt/images/lab1.qcow2,size=12,format=qcow2,bus=virtio \
  --os-variant debian11 \
  --network network=default,model=virtio \
  --graphics none \
  --console pty,target_type=serial \
  --boot loader=/usr/share/AAVMF/AAVMF_CODE.fd,loader.readonly=yes,loader.type=pflash,nvram.template=/usr/share/AAVMF/AAVMF_VARS.fd \
  --location 'https://deb.debian.org/debian/dists/bookworm/main/installer-arm64/' \
  --extra-args 'console=ttyAMA0'

Copying it works. Understanding it means you can write the next one yourself, for a different distro or a different install method, without going looking for a template.

The route to that is not memorizing twelve flags. It’s recognizing that they answer seven questions, in order.

What virt-install actually is

First, where it sits. From article 3 you know virsh is the main CLI, but virsh operates on VMs that already exist. Creating one from nothing means authoring a complete domain XML by hand.

virt-install fills that gap. It takes a short description, generates the full XML, hands it to libvirtd, and starts the installation.

   1. you give it flags describing the machine


   2. virt-install translates them into a full DOMAIN XML


   3. it hands that XML to libvirtd over the libvirt API


   4. libvirtd builds the qemu-system-aarch64 command line
      from the XML and launches QEMU


   5. QEMU boots the VM — UEFI firmware, then the installer


   6. virt-install attaches your terminal to the serial console

Note steps 2 and 3. virt-install is a convenience layer that generates XML. It never talks to QEMU directly and never touches KVM. Everything from the earlier articles still holds — this tool just sits at the top and saves you from writing the document by hand.

Two behaviours to expect. It both defines and starts the VM, dropping you straight into the installer rather than merely creating a definition. And it attaches your terminal to the guest’s serial console, so your shell becomes the VM’s console. Ctrl + ] detaches without stopping the VM; virsh console lab1 reattaches.

There’s also --print-xml, which generates and prints the XML without creating anything — useful for seeing exactly what a set of flags produces.

The seven questions

Every flag answers one of these:

  1. Where does it live?
  2. What is it called, and what shape is its hardware?
  3. What disk does it use?
  4. How does it reach the network?
  5. How do you see and talk to it?
  6. How does it boot?
  7. What OS goes on it, and how does that get there?

Take them in turn.

1. Where does it live — --connect

--connect qemu:///system

Which libvirt instance owns this VM. The previous article covered why this matters: qemu:///system and qemu:///session are separate worlds, and the default network exists only in the former.

This flag is not about the command — it’s about where the VM lives for its entire life. Get it wrong and the guest is built in an instance with no network, then appears missing from every later virsh command. Even with LIBVIRT_DEFAULT_URI set in your shell, being explicit here is worth the line.

2. Identity and hardware shape — --name, --arch, --machine, --ram, --vcpus

--name lab1
--arch aarch64 --machine virt
--ram 2048 --vcpus 2

--name becomes the handle in every subsequent virsh command. It appears as <name> in the XML.

--arch aarch64 sets the guest architecture. It defaults to the host’s, so on a Pi it’s technically redundant — but being explicit is a good habit, and it becomes mandatory the day you deliberately emulate a foreign architecture. Recall from article 2 that a non-matching architecture means no KVM acceleration at all.

--machine virt selects the virtual motherboard. From article 4: x86 emulates historical chipsets like pc and q35, while ARM64 uses virt, a synthetic board designed for VMs with no legacy hardware. On ARM64 you effectively always want virt.

--ram 2048 --vcpus 2 is KVM’s department — memory in MB, and virtual CPU count. On an 8 GB, 4-core Pi this leaves comfortable headroom for the host. Each vCPU becomes a host thread inside the QEMU process, as article 3 described.

3. Storage — --disk

--disk path=/var/lib/libvirt/images/lab1.qcow2,size=12,format=qcow2,bus=virtio

The densest flag, so learn its grammar rather than the string. It’s a comma-separated list of key=value properties describing one virtual disk.

path= — where the disk file lives. /var/lib/libvirt/images/ is libvirt’s default pool. If the file doesn’t exist, virt-install creates it via qemu-img; if it does, it reuses it.

size=12 — size in GB, needed only when creating. With qcow2 this is a maximum, not an allocation: the file starts small and grows as the guest writes.

format=qcow2 — QEMU Copy-On-Write v2. Grows on demand and supports snapshots. The alternative, raw, is a flat fully-allocated file — marginally faster, no snapshots. qcow2 is the sensible lab default.

bus=virtio — the paravirtualized fast path from article 6. This is the flag that selects virtqueues over emulated hardware, and it’s why the guest sees /dev/vda rather than /dev/sda. On any modern Linux guest, always virtio.

Four questions: a file, this big, in this format, on which bus.

4. Networking — --network

--network network=default,model=virtio

network=default attaches to the libvirt network named default — the NAT network on virbr0 with dnsmasq providing DHCP. This is the reference that fails when that network is inactive, which is exactly the port 53 problem from the previous article.

model=virtio selects the paravirtualized NIC, same reasoning as the disk. The alternative e1000 emulates a real Intel card — that’s the exit-storm device from article 6, and it exists for guests without virtio drivers.

Other modes exist for bridged and macvtap networking, which belong to a later phase. network=default,model=virtio is the everyday choice.

5. Console and display — --graphics, --console

--graphics none
--console pty,target_type=serial

--graphics none means headless — no VNC or SPICE display at all, which suits a Pi accessed over SSH.

--console pty,target_type=serial attaches a serial console and binds your terminal to it. pty is a pseudo-terminal; target_type=serial makes it a serial port rather than a virtio console. This is what virsh console lab1 reconnects to later.

Worth recalling from article 5 what this costs: every character crossing that console is a VM exit and a full round trip to QEMU’s UART model. Fine for a login shell, and precisely why nobody runs bulk I/O over it.

6. Firmware — --boot

--boot loader=/usr/share/AAVMF/AAVMF_CODE.fd,loader.readonly=yes,loader.type=pflash,nvram.template=/usr/share/AAVMF/AAVMF_VARS.fd

The ARM64-specific one, and the subject of the Security Violation failure in the previous article. ARM64 has no legacy BIOS, so guests boot through UEFI firmware. libvirt normally selects it automatically — but on Debian Bookworm it picks the Microsoft-keyed Secure Boot variant, which rejects Debian’s unsigned arm64 installer.

  • loader= — the firmware code, the plain non-Secure-Boot AAVMF_CODE.fd
  • loader.readonly=yes — firmware code is shared read-only across VMs
  • loader.type=pflash — mapped as flash memory, which is how UEFI expects to load
  • nvram.template= — the template for this VM’s private writable variables file

UEFI has two parts, and the split explains the flag: CODE is the firmware program, read-only and shared; VARS holds boot order and Secure Boot state, writable and private to each VM. The template is copied per-VM at creation.

On x86 this whole flag is usually unnecessary.

7. Install source — --location, --os-variant, --extra-args

--location 'https://deb.debian.org/debian/dists/bookworm/main/installer-arm64/'
--os-variant debian11
--extra-args 'console=ttyAMA0'

--location points at a network install tree — a directory from which virt-install fetches a kernel and initrd to netboot the installer. This is what produces the Retrieving 'linux' and Retrieving 'initrd.gz' output at launch. The URL must be the arm64 tree.

--os-variant supplies device-default hints only. As the previous article covered, debian11 on an older osinfo-db is fine — the actual OS comes from --location.

--extra-args passes kernel command-line arguments to the installer kernel. console=ttyAMA0 tells the guest kernel to put its console on the ARM serial device, so its output reaches your terminal. This is the partner to --console: that flag creates the serial port on the host side, this one tells the guest to actually use it. On x86 the equivalent is ttyS0.

The skeleton

Carry this rather than the full command:

virt-install \
  --connect <instance> \                     # 1. WHERE it lives
  --name <n> --arch <a> --machine <m> \      # 2. IDENTITY + board
  --ram <MB> --vcpus <n> \                   #    SIZE (KVM's job)
  --disk path=…,size=…,format=…,bus=virtio \ # 3. STORAGE
  --network network=…,model=virtio \         # 4. NETWORK
  --graphics none --console pty,target_type=serial \  # 5. CONSOLE
  --boot loader=…CODE.fd,…,nvram.template=…VARS.fd \  # 6. FIRMWARE
  --location <url> --os-variant <os> \       # 7. INSTALL SOURCE
  --extra-args 'console=ttyAMA0'             #    kernel console

Seven questions. The flags are just the answers.

Testing the model: the same machine, a different distro

The real test of whether you’ve learned the structure rather than the string is writing the command for a different guest. Alpine is a good case, because it exposes exactly where the model holds and where it doesn’t.

Here’s a plausible first attempt:

--os-variant alpine-rpi-3 \
--location 'https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/aarch64/alpine-rpi-3.24.1-aarch64.img.gz' \
--extra-args 'console=ttyAMA0'

Buckets 1 through 6 carry over unchanged — connection, identity, disk, network, console, firmware are all identical, because it’s the same virtual machine. That part is correct and it’s the whole point of the model.

Bucket 7 is wrong in three separate ways, and each one is instructive.

--location is the wrong mechanism. It expects an installer tree — a kernel and initrd that netboot an interactive installer, which is how Debian ships. Alpine doesn’t publish one in that form. It ships bootable ISOs instead, which means --cdrom.

The artifact is wrong too. alpine-rpi-*.img.gz is a pre-built SD-card image for flashing onto physical Raspberry Pi hardware — not VM install media of any kind. The right file is alpine-virt, the flavour built for virtual machines.

--extra-args silently does nothing. It only applies with --location, where virt-install controls the kernel command line. With --cdrom, the ISO’s own bootloader runs and the flag is ignored. Leave it off.

Corrected:

virt-install \
  --connect qemu:///system \
  --name alpine \
  --arch aarch64 --machine virt \
  --ram 1024 --vcpus 1 \
  --disk path=/var/lib/libvirt/images/alpine.qcow2,size=8,format=qcow2,bus=virtio \
  --os-variant generic \
  --network network=default,model=virtio \
  --graphics none \
  --console pty,target_type=serial \
  --boot loader=/usr/share/AAVMF/AAVMF_CODE.fd,loader.readonly=yes,loader.type=pflash,nvram.template=/usr/share/AAVMF/AAVMF_VARS.fd \
  --cdrom 'https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/aarch64/alpine-virt-3.24.1-aarch64.iso'

The install ritual differs too. Debian presents a guided menu installer. Alpine boots to a live shell where you log in as root with no password and run setup-alpine, a script that walks through keyboard, hostname, network, root password, timezone, mirror, and the disk step — where you choose sys mode and target vda to install permanently.

Afterwards, eject the ISO so the VM boots from disk:

virsh change-media alpine --eject --config
virsh start alpine
virsh console alpine

The seam worth noticing

Buckets 1–6 stayed identical between the Debian and Alpine commands. Only bucket 7 changed, because only the install method changed.

That’s the seam. The machine definition and the installation method are independent, and the three common install methods are:

   Debian-style  → --location <installer-tree-URL>  + --extra-args
   Alpine-style  → --cdrom <ISO-URL>                then a manual setup script
   cloud image   → --import <existing-disk>         no installation at all

The third is worth knowing about now, because it’s what production actually uses. A cloud image is a disk file with the OS already installed — you download it and boot it, skipping installation entirely. It has no configured user, so it relies on cloud-init to inject an account and SSH key at first boot. That’s a topic for a later phase, and doing the manual install first is what makes it legible when it arrives.

Summary

  • virt-install generates domain XML from flags, hands it to libvirtd, and starts the installer. It never talks to QEMU or KVM directly.
  • It both defines and starts the VM, and attaches your terminal to the serial console. Ctrl + ] detaches.
  • Every flag answers one of seven questions: where it lives, identity and size, disk, network, console, firmware, install source.
  • bus=virtio and model=virtio select the virtqueue fast path — a virtio disk appears as /dev/vda.
  • --console creates the serial port host-side; --extra-args 'console=ttyAMA0' tells the guest kernel to use it. Both are needed.
  • The --boot firmware line is ARM64-specific and exists to avoid libvirt’s Secure Boot default.
  • Changing distro usually changes only bucket 7. --location needs an installer tree; --cdrom needs an ISO; --extra-args works only with the former.

Comments

get new posts

About one email a week, and only when there is something new.

Subscribe →