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.122.2-254 range
- Sets up SSH access via your GitHub public keys
- Pre-installs useful packages including Docker, Python 3.13.7, and a minimal desktop environment (Openbox)
Prerequisites
Before running this script, make sure you have:
- A Linux host with KVM/QEMU installed
-
libvirtandvirt-installpackages -
cloud-localds(fromcloud-image-utilspackage) - A bridge network interface (
br0) configured -
wgetfor 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="/var/lib/libvirt/images"
SUBNET="192.168.122"
GATEWAY="$SUBNET.1"
DHCP_START=2
DHCP_END=254
if [ $NUM_VMS -gt $((DHCP_END - DHCP_START)) ]; then
echo "Error: Max 5 VMs to stay within $DHCP_START-$DHCP_END 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 $DHCP_START $DHCP_END); 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 $SUBNET.$DHCP_START-$DHCP_END. 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
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 \ <keybind key="C-A-t">\n <action name="Execute">\n <command>xterm</command>\n </action>\n </keybind>' /home/ubuntu/.config/openbox/rc.xml
- sed -i '/<\/keyboard>/i \ <keybind key="C-A-b">\n <action name="Execute">\n <command>chromium</command>\n </action>\n </keybind>' /home/ubuntu/.config/openbox/rc.xml
- sudo apt-get install -y 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
- cd /tmp/
- wget https://www.python.org/ftp/python/3.13.7/Python-3.13.7.tgz
- tar xzf Python-3.13.7.tgz
- 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: [8.8.8.8,8.8.4.4]
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 default \
--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 <name>', 'virsh console <name>' for access. Clean up images/ISOs when done."
If you want to use another version of Python, you can find here and update the area into the runcmd that install the python
When the machine start you need to wait the cloud-init to configure everything, you can see the logs with sudo tail -f /var/log/cloud-init-output.log
The network that I used was this
<network>
<name>default</name>
<uuid>2dba4e25-611d-44db-9b54-ce38bd7c5a62</uuid>
<forward mode="nat">
<nat>
<port start="1024" end="65535"/>
</nat>
</forward>
<bridge name="virbr0" stp="on" delay="0"/>
<mac address="ad:98:81:e2:9a:74"/>
<dns>
<forwarder addr="8.8.8.8"/>
<forwarder addr="8.8.4.4"/>
</dns>
<ip address="192.168.122.1" netmask="255.255.255.0">
<dhcp>
<range start="192.168.122.2" end="192.168.122.254"/>
</dhcp>
</ip>
</network>
Thanks for read until there :)
Top comments (1)
Into the virt-manager you can use ctrl + alt + t to open the terminal and ctrl + alt + b for the chromium