In production, BookMyHost provisions VMs through Apache CloudStack 4.22 on KVM, with disks on Ceph RBD. CloudStack handles templates, user data and IP allocation for us. Away from production, on a single libvirt host, a CI runner or a developer's workstation, you get none of that. You end up running virt-install with fourteen flags and a hand-written user-data file, and getting one of them wrong costs a boot cycle.
cloud-init-vm.sh packs that into one command that ends with an ssh line you can paste. This post walks through what it does at each step, why, and the one sharp edge in its design that you need to know about before you rely on it for long-lived VMs.
What one command does
cloud-init-vm.sh --name web1 --ssh-key ~/.ssh/id_ed25519.pub --network default
- Downloads the official Ubuntu cloud image (
noblefor 24.04,jammyfor 22.04) into a cache and verifies it against the release'sSHA256SUMS. - Creates a qcow2 overlay on top of that image, sized to
--disk(default 20 GiB). - Generates cloud-init
user-data,meta-dataandnetwork-config. - Builds a NoCloud seed ISO.
- Runs
virt-install --importwith virtio disk and NIC, a guest-agent channel, a serial console and virtio-rng. - Waits for an IP address and prints
ssh ubuntu@<ip>.
The dependencies are packaged on Ubuntu and Debian:
sudo apt install qemu-utils virtinst libvirt-clients cloud-image-utils curl
cloud-image-utils provides cloud-localds. If it is missing, the script falls back to genisoimage or xorrisofs.
Design decisions
Verify the image, cache it, recheck weekly
The image URL is $MIRROR/$CODENAME/current/$CODENAME-server-cloudimg-amd64.img, where the mirror defaults to https://cloud-images.ubuntu.com and can be changed with UBUNTU_CLOUD_MIRROR. The download goes to a .part file. The script then looks up the expected hash in SHA256SUMS, compares it, and only renames the file into the cache if they match. A mismatch deletes the partial file and exits with code 3. A truncated or tampered download never becomes a base image.
On later runs the cached image is used straight away. If it is more than seven days old, the script fetches SHA256SUMS again. If upstream has published a new image, it downloads and verifies that one. If the mirror cannot be reached, it warns and uses the cached copy, so an offline lab still works.
A thin overlay, not a copy
qemu-img create -q -f qcow2 -F qcow2 -b "$BASE_IMG" "$DISK" "${DISK_GB}G"
The VM disk is a new qcow2 file whose backing file is the cached image. Creating it is instant, and it only grows with the blocks the VM writes. -F qcow2 states the backing format explicitly. Recent QEMU versions refuse to guess it, and guessing was a security problem anyway. Ubuntu's cloud images grow their root partition on first boot, so the extra space from --disk is used without any extra step.
cloud-init through NoCloud
The generated user-data creates one sudo user (default ubuntu) with your keys and passwordless sudo, and switches off password SSH unless you supply a hash. It installs qemu-guest-agent every time:
#cloud-config
hostname: web1
manage_etc_hosts: true
users:
- name: ubuntu
groups: [sudo]
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
lock_passwd: true
ssh_authorized_keys:
- 'ssh-ed25519 AAAA... you@laptop'
ssh_pwauth: false
package_update: true
packages:
- qemu-guest-agent
runcmd:
- [systemctl, enable, --now, qemu-guest-agent]
The guest agent is not optional in our view. Without it the host cannot ask the VM for its IP address, cannot freeze filesystems for a consistent snapshot, and cannot shut it down cleanly. Our own CloudStack Ubuntu template ships with the agent preinstalled for the same reasons.
meta-data gets an instance-id of <name>-<unix time>. That ID changes every time the script runs, so cloud-init treats the VM as a new instance. For VMs built from the official image this is what you want. network-config is version 2. It matches the first en* interface, renames it eth0, and sets either dhcp4: true or a static address, default route and resolvers:
cloud-init-vm.sh --name db1 --release 22.04 --vcpus 4 --ram 8192 --disk 100 \
--bridge br0 --ip 10.0.0.21/24 --gateway 10.0.0.1 --dns 10.0.0.1 \
--packages postgresql,htop --runcmd "systemctl enable --now postgresql"
The seed ISO is set to mode 0600 because it can contain a password hash. To set a console password, pass --password-hash-file pointing at a 0600 file that holds a crypt(3) hash, never a plain password. If you have your own cloud-config, --user-data FILE uses it as is.
virt-install flags
virt-install --connect qemu:///system --name web1 --memory 2048 --vcpus 2 \
--cpu host-passthrough --os-variant ubuntu24.04 --import \
--disk path=/var/lib/libvirt/images/web1.qcow2,format=qcow2,bus=virtio,discard=unmap \
--disk path=/var/lib/libvirt/images/web1-seed.iso,device=cdrom,readonly=on \
--network network=default,model=virtio --graphics none --console pty,target_type=serial \
--channel unix,target_type=virtio,name=org.qemu.guest_agent.0 \
--rng /dev/urandom --noautoconsole
That is the exact shape the script builds. A few of these choices matter. discard=unmap lets TRIM inside the guest shrink the qcow2 file. host-passthrough gives the guest the host's CPU features at the cost of live migration to different hardware. virtio-rng means a fresh VM is not short of entropy when it generates SSH host keys on first boot. --graphics none plus a serial console means virsh console web1 works whenever SSH does not.
Waiting for an address
The script polls every 3 seconds, for up to --wait seconds (default 180):
virsh domifaddr --source agent, which asks the guest agent.virsh domifaddr --source lease, which reads the libvirt DHCP lease and only works on libvirt-managed networks.- With
--ip, the configured address, as soon as the agent answersguest-ping.
If nothing turns up, it exits with code 6 and leaves the VM running so you can find out why.
Automation: idempotent, JSON, dry-run
# print every command and the generated cloud-init, change nothing
cloud-init-vm.sh --name test --ssh-key "ssh-ed25519 AAAA... me" --dry-run
# safe to re-run from Ansible or CI
cloud-init-vm.sh --name web1 --ssh-key ~/.ssh/id_ed25519.pub --idempotent --json
On success --json prints one object, for example {"name":"web1","release":"24.04","vcpus":2,"ram_mb":2048,"disk":"...","ip":"192.168.122.57","user":"ubuntu","ssh":"ssh ubuntu@192.168.122.57"}, so jq -r .ip gets you the address.
| Exit | Meaning |
|---|---|
| 0 | VM created (or exists with --idempotent) |
| 1 | usage error |
| 2 | missing dependency |
| 3 | image download / checksum failure |
| 4 | libvirt / virt-install failure |
| 5 | domain or disk already exists |
| 6 | timed out waiting for an IP (the VM is still running) |
An EXIT trap removes the overlay and the seed ISO if anything fails after the disk is created and before virt-install succeeds. A failed run does not leave a half-built disk that would block the next attempt with exit 5.
Failure modes, including the one in the design
The base image changes under existing overlays. This is the sharp edge. Overlays point at the cache path, for example /var/lib/libvirt/images/cloud-cache/noble-server-cloudimg-amd64.img. When the weekly check finds a newer upstream image, the script replaces that file in place. VMs that are running keep the old copy open and carry on. The next time one of them starts, though, its overlay sits on top of a different base image and the filesystem inside will be corrupt. For throwaway and CI VMs this does not matter. For anything you keep, do one of these:
# flatten a VM so it no longer depends on the cache (VM shut off)
qemu-img convert -O qcow2 web1.qcow2 web1-flat.qcow2 && mv web1-flat.qcow2 web1.qcow2
# or give long-lived VMs their own cache directory and never refresh it
CLOUD_IMAGE_CACHE=/var/lib/libvirt/images/base-2026-10 cloud-init-vm.sh --name keep1 ...
The second option only delays the refresh, because the weekly check still runs against that directory. Flattening is the reliable fix.
"Has an IP" does not mean "SSH is ready." The agent can report an address while cloud-init is still installing packages. In automation, wait on the guest itself:
ssh -o StrictHostKeyChecking=accept-new ubuntu@"$IP" cloud-init status --wait
A bridge plus DHCP depends on apt. On a host bridge there is no libvirt lease to read, so the agent is the only source of the IP. The agent is installed by cloud-init, which needs a working network and a reachable package mirror. If the mirror is unreachable, you get exit 6 even though the VM booted fine. Use --network for a libvirt network, a static --ip, or an image with the agent already installed.
--user-data replaces everything. A file you supply is used verbatim, so the script's guest-agent install is not included. Add qemu-guest-agent yourself, or the IP wait will only work through DHCP leases.
Other limits to know: --ip is IPv4 only. Only one NIC is configured. The seed ISO stays attached as a CD-ROM after first boot, holding your public keys and any password hash. Find its target with virsh domblklist web1 and eject it with virsh change-media web1 <target> --eject if that bothers you. With qemu:///system, your user needs to be in the libvirt group or the script has to run as root.
Running it on a schedule
This is a provisioning tool, not a daemon, but a nightly rebuild of a disposable VM makes a good smoke test for the host, the mirror and your cloud-config:
# /etc/systemd/system/smoke-vm.service
[Unit]
Description=Rebuild the disposable smoke-test VM
[Service]
Type=oneshot
ExecStartPre=-/usr/bin/virsh -c qemu:///system destroy smoke1
ExecStartPre=-/usr/bin/virsh -c qemu:///system undefine smoke1 --remove-all-storage
ExecStart=/usr/local/sbin/cloud-init-vm.sh --name smoke1 --ssh-key /root/.ssh/id_ed25519.pub --network default --json
# /etc/systemd/system/smoke-vm.timer
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
[Install]
WantedBy=timers.target
The - prefix lets the cleanup steps fail when there is no VM yet. A non-zero exit from the main command marks the unit as failed, so it shows up in systemctl --failed and in whatever already watches that. If you prefer cron, 15 3 * * * root /usr/local/sbin/cloud-init-vm.sh ... with the same cleanup in a wrapper script does the same job.
If you would rather not run the KVM host at all, BookMyHost sells NVMe KVM VPS hosted in India.
Get the script
- Repository: https://github.com/geetmangal/bmh-infra-scripts
- File: https://github.com/geetmangal/bmh-infra-scripts/blob/main/kvm/cloud-init-vm.sh
MIT-licensed, a single Bash file, ShellCheck-clean. --help lists every flag and environment variable (CLOUD_IMAGE_CACHE, LIBVIRT_IMAGE_DIR, LIBVIRT_DEFAULT_URI, UBUNTU_CLOUD_MIRROR, NO_COLOR). Bug reports about other distros' cloud images are welcome.