NeuroDynamic.Tech
Sign inJoin

Headless VMs: virt-install, cloud-init and snapshots without a desktop

A virtual machine on a server you only ever touch over SSH. No desktop, no console passwords — your SSH key is injected at first boot, and the guest survives host reboots.

The founder · 11 min read ·

DEEP END

Tested for real: I ran every command in this guide top-to-bottom on a fresh Ubuntu 24.04 virtual machine (4 CPUs, 8 GB RAM) on 13 August 2026. The run carried straight on into the companion guide. Versions at test time: Ubuntu 24.04.4 LTS, libvirt 10.0.0, QEMU 8.2.2, virt-install 4.1.0.

Found a problem? Tell me via the contact page.

Placeholders: anything written in CAPS-WITH-DASHES, like YOUR-VM-IP-HERE, is a value you replace with your own — here that means the address from your lease table. $USER is different — leave it exactly as written; the shell fills in your username for you.

What you'll end up with

A virtual machine on a server you only ever touch over SSH. No desktop, no virt-manager, no console passwords — your SSH key is injected at first boot, and the guest survives host reboots.

The finished state from my test run — a guest you can find and reach without ever seeing a screen:

$ virsh net-dhcp-leases default
 Expiry Time           MAC address         Protocol   IP address          Hostname   Client ID or DUID
------------------------------------------------------------------------------------------------------
 2026-08-13 18:00:46   52:54:00:04:09:f8   ipv4       192.168.122.82/24   ubuntu     ff:56:...:20:99

This guide has a companion: Snapshots, cloning, and the identity trap. It continues from this exact end state — rewindable snapshots, then a clone that breaks and gets properly fixed. Do this one first.

Who this is for

DEEP END. You are comfortable in a terminal and probably manage a home server over SSH. We still verify every step — that habit does not stop at the deep end.

Time and cost

The commands for this guide and its companion took me about 7 minutes of measured run time in total, as one continuous run. Add waiting on guest boots — my guests took 35-55 seconds to become reachable. Budget 50 minutes for this half. Cost: nothing; every tool is in Ubuntu's archive.

One honesty note: my test machine is itself a virtual machine, so my guests are nested and their disk speed is the worst case. Your times may be better on real hardware, but storage and network speed still matter.

Words you'll meet

  • Cloud image — a ready-installed Ubuntu disk image from Ubuntu. No installer, no ISO. We import it and boot in seconds. (basics)
  • Seed — a tiny ISO we build with cloud-localds, carrying first-boot settings (cloud-init reads it): hostname and your SSH public key. (basics)
  • DHCP lease — the network's record of "this machine has this IP". virsh net-dhcp-leases is how you find a headless guest. (basics)

Before you start

  • An Ubuntu machine or server (I tested 24.04) with sudo, reachable over SSH.
  • The BASIC guide Your first virtual machine: KVM on Ubuntu — this guide assumes those ideas (hypervisor, cloud image, the libvirt group) and moves faster.
  • Around 4 GB free disk and 2 GB free RAM while the guest runs (4 GB of each if you continue into the companion guide).

The steps

Step 1 — install the headless stack

Same stack as the BASIC guide minus virt-manager, plus the seed-building tool up front:

sudo apt-get update
sudo apt-get install -y qemu-kvm libvirt-daemon-system virtinst cloud-image-utils

Your output will vary, but look for:

11 upgraded, 218 newly installed, 0 to remove and 15 not upgraded.
Need to get 142 MB of archives.
After this operation, 616 MB of additional disk space will be used.

84 seconds on my machine. Honest observation: the headless set is barely smaller than the GUI set — virtinst drags in most of the same Python stack. The win is no GUI processes, not disk space.

Check it worked.

virsh --version

You should see something like:

10.0.0

Your version may be newer — that's fine.

Step 2 — group membership and the system hypervisor

The BASIC guide covers the why; here is the condensed do. Add yourself to the group:

sudo adduser $USER libvirt

Make the system hypervisor your default:

echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc

Then log out and back in — on a server that means closing your SSH session and reconnecting.

Check it worked.

groups

Your output will vary, but look for:

tester libvirt

And:

virsh uri

The important line is:

qemu:///system

Step 3 — make a throwaway SSH key for your guests

Console passwords do not scale past one VM. We generate a dedicated keypair and inject the public half into every guest at first boot.

ssh-keygen -t ed25519 -f ~/.ssh/vm-lab-key -N "" -C "vm-lab"

Check it worked.

cat ~/.ssh/vm-lab-key.pub

You should see something like:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDiMZSWQm0kM+/4CAU3i2Zh/3B5ZHqEFHhZq5c0Z/QD5 vm-lab

One line, starting ssh-ed25519. That line is about to be baked into a VM.

Step 4 — image, seed, and virt-install

Download the noble cloud image (596 MB — 9 seconds on my line, your mileage will vary):

wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img

Copy it into place as vm1's disk and give it room:

sudo cp ~/noble-server-cloudimg-amd64.img /var/lib/libvirt/images/vm1.qcow2
sudo qemu-img resize /var/lib/libvirt/images/vm1.qcow2 10G

Build the seed. This one-liner writes the cloud-init file with your real public key spliced in — no editing needed:

mkdir -p ~/seeds
{ echo "#cloud-config"; echo "hostname: vm1"; echo "ssh_authorized_keys:"; echo "  - $(cat ~/.ssh/vm-lab-key.pub)"; } > ~/seeds/vm1-user-data

Look at what you made — always look:

cat ~/seeds/vm1-user-data

Your output will vary, but look for:

#cloud-config
hostname: vm1
ssh_authorized_keys:
  - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDiMZSWQm0kM+/4CAU3i2Zh/3B5ZHqEFHhZq5c0Z/QD5 vm-lab

Pack and place it:

cloud-localds ~/seeds/vm1-seed.img ~/seeds/vm1-user-data
sudo cp ~/seeds/vm1-seed.img /var/lib/libvirt/images/vm1-seed.img

Create the machine — 1 vCPU and 2 GB, imported, headless:

virt-install \
  --name vm1 \
  --memory 2048 \
  --vcpus 1 \
  --disk path=/var/lib/libvirt/images/vm1.qcow2 \
  --disk path=/var/lib/libvirt/images/vm1-seed.img,device=cdrom \
  --osinfo ubuntu24.04 \
  --import \
  --network network=default \
  --graphics none \
  --noautoconsole

A passing run includes:

WARNING  Requested memory 2048 MiB is less than the recommended 3072 MiB for OS ubuntu24.04

Starting install...
Domain creation completed.

The WARNING is cosmetic for a lab guest. Creation took 6 seconds in my test.

Check it worked.

virsh list --all

You should see something like:

 Id   Name   State
----------------------
 1    vm1    running

Step 5 — find it and SSH in

A headless guest tells you its address through the DHCP lease table. Give it a moment — mine appeared 40 seconds after creation:

virsh net-dhcp-leases default

Your output will vary, but look for:

 Expiry Time           MAC address         Protocol   IP address          Hostname   Client ID or DUID
------------------------------------------------------------------------------------------------------
 2026-08-13 18:00:46   52:54:00:04:09:f8   ipv4       192.168.122.82/24   ubuntu     ff:56:...:20:99

(The hostname shows ubuntu because the guest asked for DHCP before cloud-init renamed it — it corrects itself on the next lease renewal.)

SSH in with the lab key, using the IP from your table:

ssh -i ~/.ssh/vm-lab-key ubuntu@YOUR-VM-IP-HERE

You should see something like:

Warning: Permanently added '192.168.122.82' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-136-generic x86_64)
ubuntu@vm1:~$

Check it worked. From inside the guest:

cloud-init status

The important line is:

status: done

Then exit back to the host. My guest accepted the key 55 seconds after virt-install returned.

Step 6 — lifecycle: off, on, and on-at-boot

Clean shutdown (9 seconds in my test):

virsh shutdown vm1

Confirm, restart:

virsh list --all
virsh start vm1

A home-server VM should survive the host rebooting. That is one flag:

virsh autostart vm1

The important line is:

Domain 'vm1' marked as autostarted

Check it worked.

virsh dominfo vm1 | grep Autostart

A passing run includes:

Autostart:      enable

That is the headless workflow: create, find, connect, manage. When you are ready to copy machines instead of building them, continue with Snapshots, cloning, and the identity trap — it starts from exactly this state.

Something went wrong?

Everything below happened in my recorded run.

  • You see WARNING Requested memory 2048 MiB is less than the recommended 3072 MiB → it means virt-install is cautious → ignore for lab guests.
  • You see groups: cannot find name for group ID 113 after the Undo → it means your shell still holds the deleted group's ID → cosmetic; gone after you log out and in.
  • The lease table shows hostname ubuntu instead of vm1 → it means the guest requested DHCP before cloud-init renamed it → it corrects itself at renewal; nothing to do.

Undo all of this

Tested for real, in this order — as one combined undo with the companion guide. If you did that guide too, run its undo first; this one then clears the rest.

Force the guest off:

virsh destroy vm1

Delete vm1 — note the extra flag. It matters if you took the companion guide's snapshot: a plain undefine refuses while snapshot metadata exists (I captured the refusal in testing), and the flag is harmless if you have no snapshots:

virsh undefine vm1 --remove-all-storage --snapshots-metadata

A passing run includes:

Domain 'vm1' has been undefined
Volume 'vda'(/var/lib/libvirt/images/vm1.qcow2) removed.
Volume 'sda'(/var/lib/libvirt/images/vm1-seed.img) removed.

Remove seeds, image, throwaway key, the bashrc line and the group membership:

rm -r ~/seeds ~/noble-server-cloudimg-amd64.img ~/.ssh/vm-lab-key ~/.ssh/vm-lab-key.pub
sed -i '/LIBVIRT_DEFAULT_URI/d' ~/.bashrc
sudo gpasswd -d $USER libvirt

Remove the packages — including the qemu engine packages that survive autoremove (same wrinkle as the BASIC guide, hit again here). libguestfs-tools arrives with the companion guide; listing it here is harmless if you never installed it:

sudo apt-get remove --purge -y qemu-kvm libvirt-daemon-system virtinst cloud-image-utils libguestfs-tools
sudo apt-get autoremove --purge -y
sudo apt-get remove --purge -y qemu-system-x86 qemu-system-gui qemu-system-common qemu-utils qemu-block-extra ipxe-qemu
sudo apt-get autoremove --purge -y

Delete the leftover bridge and data folders:

sudo ip link delete virbr0
sudo rm -rf /var/lib/libvirt /etc/libvirt

Check it worked.

virsh --version

You should see something like:

bash: virsh: command not found

That error is the pass mark. My full undo took 56 seconds of package work plus a couple of seconds of VM removal.

Where to go next


Last tested: 13 August 2026 on Ubuntu 24.04.4 LTS. Versions: libvirt 10.0.0, QEMU 8.2.2, virt-install 4.1.0, cloud-image-utils 0.33. Tested as one continuous run with the snapshots-and-cloning companion guide.


Tried it? Improved it?

Tell the forum what worked and what didn’t: real experience beats recommendations, and the best answers get folded back into this guide with credit.

Related guides