Automating Ubuntu VM Creation with libvirt (KVM/QEMU) and Cloud-Init

Ever needed to spin up multiple Ubuntu VMs quickly for testing, development, or learning? In this post, I’ll walk you through a bash script that automates the entire process using libvirt/KVM and cloud-init.

What This Script Does

  • Downloads the official Ubuntu Noble cloud image automatically
  • Creates multiple VMs with customizable resources (memory, CPUs, disk)
  • Configures static IPs in the 192.168.1.100-150 range
  • Sets up SSH access via your GitHub public keys
  • Pre-installs useful packages including Docker, Python 3.13, and a minimal desktop environment (Openbox)

Prerequisites

Before running this script, make sure you have:

  • A Linux host with KVM/QEMU installed
  • libvirt and virt-install packages
  • cloud-localds (from cloud-image-utils package)
  • A bridge network interface (br0) configured
  • wget for downloading the cloud image

Below is the script

#!/bin/bash

usage() {
  cat <<EOF
Usage: $(basename "$0") [OPTIONS]

Launch Ubuntu VMs using libvirt/KVM with cloud-init configuration.

OPTIONS:
  -n, --num-vms NUM       Number of VMs to create (default: 1, max: 5)
  -m, --memory GB         Memory per VM in GB (default: 1, max: 8)
  -c, --cpus NUM          Number of CPUs per VM (default: 1, max: 2)
  -d, --disk-size GB      Disk size in GB (default: 10, min: 10)
  -g, --github USER       GitHub username for SSH key import (required)
  -h, --help              Show this help message and exit

EXAMPLES:
  $(basename "$0") -g myuser
  $(basename "$0") -n 3 -m 2 -c 2 -d 20 -g myuser
  $(basename "$0") --num-vms 5 --memory 4 --github myuser

NOTES:
  - VMs will be assigned IPs in the range 192.168.1.100-150
  - The script will automatically find available network octets
  - Ubuntu Noble cloud image will be downloaded if not present
EOF
  exit 0
}

NUM_VMS=1
MEMORY=1
CPUS=1
DISK_SIZE=10
GITHUB_USER=""

while [[ $# -gt 0 ]]; do
  case $1 in
    -n|--num-vms)
      NUM_VMS="$2"
      shift 2
      ;;
    -m|--memory)
      MEMORY="$2"
      shift 2
      ;;
    -c|--cpus)
      CPUS="$2"
      shift 2
      ;;
    -d|--disk-size)
      DISK_SIZE="$2"
      shift 2
      ;;
    -g|--github)
      GITHUB_USER="$2"
      shift 2
      ;;
    -h|--help)
      usage
      ;;
    *)
      echo "Error: Unknown option $1"
      echo "Use -h or --help for usage information."
      exit 1
      ;;
  esac
done

if [ -z "$GITHUB_USER" ]; then
  echo "Error: GitHub username is required for SSH key import."
  echo "Use -g or --github to specify your GitHub username."
  echo "Use -h or --help for usage information."
  exit 1
fi

BASE_IMG="noble-server-cloudimg-amd64.img"
IMG_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
IMG_DIR="https://dev.to/var/lib/libvirt/images"
SUBNET="192.168.1"
GATEWAY="$SUBNET.1"

if [ $NUM_VMS -gt 5 ]; then
  echo "Error: Max 5 VMs to stay within 100-150 range."
  exit 1
fi

if [ $MEMORY -gt 8 ]; then
  echo "Error: Max memory is 8GB"
  exit 1
fi

if [ $CPUS -gt 2 ]; then
  echo "Error: Max CPUs is 2"
  exit 1
fi

if [ $DISK_SIZE -lt 10 ]; then
  echo "Error: Disk size must be at least 10GB to accommodate base image."
  exit 1
fi

if [ ! -f "$IMG_DIR/$BASE_IMG" ]; then
  echo "Downloading Ubuntu Noble cloud image..."
  wget -O "$IMG_DIR/$BASE_IMG" "$IMG_URL"
  if [ $? -ne 0 ]; then
    echo "Error: Failed to download the base image from $IMG_URL"
    exit 1
  fi
  echo "Base image downloaded successfully."
fi

is_octet_available() {
  local octet=$1
  local ip="$SUBNET.$octet"

  for vm in $(virsh list --all --name 2>/dev/null); do
    if [ -n "$vm" ]; then
      if [ "$vm" = "server-$octet" ]; then
        return 1
      fi
    fi
  done

  if ping -c 1 -W 1 "$ip" &>/dev/null; then
    return 1
  fi

  return 0
}

AVAILABLE_OCTETS=()
for octet in $(seq 100 150); do
  if is_octet_available $octet; then
    AVAILABLE_OCTETS+=($octet)
  fi
  if [ ${#AVAILABLE_OCTETS[@]} -ge $NUM_VMS ]; then
    break
  fi
done

if [ ${#AVAILABLE_OCTETS[@]} -lt $NUM_VMS ]; then
  echo "Error: Not enough available network addresses. Found ${#AVAILABLE_OCTETS[@]}, need $NUM_VMS."
  echo "Available range is 192.168.1.100-150. Some addresses may already be in use."
  exit 1
fi

echo "Found ${#AVAILABLE_OCTETS[@]} available network addresses."

for i in $(seq 0 $(($NUM_VMS - 1))); do
  OCTET=${AVAILABLE_OCTETS[$i]}
  HOSTNAME="server-$OCTET"
  IMG="$IMG_DIR/vm-$OCTET.qcow2"
  SEED="$IMG_DIR/seed-$OCTET.iso"
  USER_DATA="user-data-$OCTET.yaml"
  NETWORK_DATA="network-data-$OCTET.yaml"

  qemu-img create -f qcow2 -F qcow2 -b "$IMG_DIR/$BASE_IMG" "$IMG" ${DISK_SIZE}G

  cat <<EOF > "$USER_DATA"
#cloud-config
hostname: $HOSTNAME

package_update: true
package_upgrade: true

users:
  - name: ubuntu
    shell: /bin/bash
    groups: [users, sudo]
    sudo: "ALL=(ALL) NOPASSWD:ALL"
    lock_passwd: false
    passwd: 123456789
    ssh_import_id:
      - gh:$GITHUB_USER

packages:
  - apt-transport-https
  - xorg
  - xterm
  - openbox
  - chromium-browser
  - spice-vdagent
  - make
  - build-essential
  - libssl-dev
  - zlib1g-dev
  - libbz2-dev
  - libreadline-dev
  - libsqlite3-dev
  - wget
  - curl
  - llvm
  - libncurses5-dev
  - libncursesw5-dev
  - xz-utils
  - tk-dev
  - liblzma-dev t
  - k-dev

runcmd:
  - mkdir -p /home/ubuntu/.config/openbox
  - wget https://raw.githubusercontent.com/Mikachu/openbox/refs/heads/master/data/rc.xml -P /home/ubuntu/.config/openbox
  - sed -i '/</keyboard>/i    n     n       xtermn     n   ' /home/ubuntu/.config/openbox/rc.xml
  - sed -i '/</keyboard>/i    n     n       chromiumn     n   ' /home/ubuntu/.config/openbox/rc.xml
  - cd /tmp
  - sudo wget https://www.python.org/ftp/python/3.13.7/Python-3.13.7.tar.xz
  - sudo tar -xf Python-3.13.7.tar.xz
  - cd Python-3.13.7
  - sudo ./configure --enable-optimizations
  - sudo make -j$(nproc)
  - sudo make altinstall
  - sudo ln -s /usr/local/bin/python3.13 /usr/local/bin/python
  - sudo ln -s /usr/local/bin/pip3.13 /usr/local/bin/pip
  - echo 'if [[ -z $DISPLAY ]] && [[ $(tty) = /dev/tty1 ]]; then exec startx; fi' | sudo tee -a /home/ubuntu/.profile
  - echo "alias displays='ps e | grep -Po " DISPLAY=[\.0-9A-Za-z:]* " | sort -u'" >> /home/ubuntu/.bashrc
  - sudo groupadd -g 3001 docker
  - sudo usermod -aG docker ubuntu
  - curl -fsSL https://get.docker.com -o get-docker.sh
  - sudo sh get-docker.sh
  - sudo reboot
EOF

  cat <<EOF > "$NETWORK_DATA"
#cloud-config
network:
  version: 2
  ethernets:
    interface0:
      match:
        name: "en*s0"
      dhcp4: false
      addresses: [$SUBNET.$OCTET/24]
      routes:
        - to: default
          via: $GATEWAY
      nameservers:
        addresses: [1.1.1.1]
EOF

  echo "$(cat $USER_DATA)"
  echo "$(cat $NETWORK_DATA)"

  cloud-localds --network-config "$NETWORK_DATA" "$SEED" "$USER_DATA"

  rm "$USER_DATA"
  rm "$NETWORK_DATA"

  virt-install 
    --name "$HOSTNAME" 
    --memory $(( $MEMORY * 1024 )) 
    --vcpus $CPUS 
    --disk path="$IMG",bus=virtio,format=qcow2 
    --disk path="$SEED",bus=virtio,format=raw 
    --network bridge=br0,model=virtio 
    --os-variant ubuntu24.04 
    --graphics spice,listen=0.0.0.0 
    --channel spicevmc 
    --video virtio 
    --import 
    --noautoconsole

  echo "Launched VM $((i + 1)): $HOSTNAME with IP $SUBNET.$OCTET (disk size: ${DISK_SIZE}GB)"
done

echo "All $NUM_VMS VMs launched. Use 'virsh list --all' to see VMs, 'virsh start/stop/destroy ', 'virsh console ' for access. Clean up images/ISOs when done."
Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Headless WordPress + Astro: El combo definitivo para sitios ultra-rápidos

Next Post

[Boost]

Related Posts