Tutorials

Build a Production KVM Virtual Lab with Bridged Networking and Cloud-Init

Build a Production KVM Virtual Lab with Bridged Networking and Cloud-Init

A useful virtual lab should behave like infrastructure, not a collection of disposable desktop VMs. It needs predictable addressing, bounded resource consumption, repeatable provisioning, observable boot failures, and a recovery path that still works after the host disk has disappointed you.

This tutorial builds that system on an Ubuntu Server KVM host. The result is a two-vCPU Ubuntu guest named lab01, connected directly to the physical LAN through br0, provisioned by cloud-init, protected by resource limits, and covered by clean snapshots and independently recoverable backups.

Architecture, assumptions, and trade-offs

The example LAN is 192.168.50.0/24. Its gateway and DNS resolver are 192.168.50.1; the host uses 192.168.50.2; and lab01 uses 192.168.50.10. Confirm that these addresses are outside your DHCP pool and unused before proceeding.

The host has one wired interface, enp3s0. It becomes a port of br0, so the host address moves from the physical interface to the bridge. Perform that change from a local or out-of-band console: even a correct network migration can terminate an SSH session.

A physical bridge gives guests first-class LAN presence and avoids NAT or host port forwarding. The cost is exposure: the upstream switch sees another MAC address, and the guest must be secured like any physical server. Ordinary Wi-Fi client interfaces generally cannot provide this form of transparent bridging because access points commonly reject additional source MAC addresses.

The storage layout uses an immutable Ubuntu cloud image as a qcow2 backing file and a thin overlay for the VM. This saves space, but the backing image becomes part of the running disk chain and must never be replaced in place. Backups will therefore flatten the chain into standalone qcow2 images.

Prerequisites and project structure

Use an Ubuntu Server host with hardware virtualization enabled, a wired Ethernet connection, and enough reserved capacity for the host itself. The administrative account needs sudo access and an existing Ed25519 SSH public key.

Install KVM, libvirt, image utilities, cloud-init tooling, and the VM installer. Adding the account to libvirt and kvm takes effect after signing out and back in.

sudo apt update
sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients \
  virtinst qemu-utils cloud-image-utils

sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt,kvm "$USER"

sudo virt-host-validate qemu
sudo virsh --connect qemu:///system list --all

The lab keeps durable artifacts under one explicit path:

/srv/kvm-lab/
├── cloud-init/
│   ├── lab01-meta-data
│   ├── lab01-network-config
│   └── lab01-user-data
├── images/
│   ├── noble-server-cloudimg-amd64.img
│   └── lab01.qcow2
├── seeds/
│   └── lab01-seed.img
├── backups/
└── scripts/
    └── backup-lab01.sh

Create it with permissions that let the system libvirt process traverse and read the storage:

sudo install -d -o "$USER" -g kvm -m 0770 \
  /srv/kvm-lab/{cloud-init,images,seeds,backups,scripts}

Build the host bridge safely

The following Netplan configuration assumes Ubuntu Server’s systemd-networkd renderer. Save it as /etc/netplan/60-kvm-bridge.yaml, but first reconcile any existing Netplan file that configures enp3s0. An interface must not receive conflicting definitions from multiple files.

network:
  version: 2
  renderer: networkd
  ethernets:
    enp3s0:
      dhcp4: false
      dhcp6: false
  bridges:
    br0:
      interfaces:
        - enp3s0
      addresses:
        - 192.168.50.2/24
      routes:
        - to: default
          via: 192.168.50.1
      nameservers:
        addresses:
          - 192.168.50.1
      parameters:
        stp: false
        forward-delay: 0
      dhcp4: false
      dhcp6: false

Use netplan try from the host console. It rolls back automatically unless you confirm the working configuration:

sudo chmod 0600 /etc/netplan/60-kvm-bridge.yaml
sudo netplan generate
sudo netplan try

ip -brief address show br0
ip route
bridge link show

Do not configure the host address on both enp3s0 and br0. The physical interface should have no layer-three address after migration.

Create the base image and overlay

Download the current Ubuntu 24.04 LTS cloud image and verify it against the checksum published beside it. A checksum retrieved over the same HTTPS origin detects corruption; environments requiring stronger provenance should additionally verify Ubuntu’s signed checksum file according to their trust policy.

cd /srv/kvm-lab/images

curl --fail --location --remote-name \
  https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
curl --fail --location --remote-name \
  https://cloud-images.ubuntu.com/noble/current/SHA256SUMS

sha256sum --check --ignore-missing SHA256SUMS

qemu-img info noble-server-cloudimg-amd64.img
qemu-img create -f qcow2 -F qcow2 \
  -b /srv/kvm-lab/images/noble-server-cloudimg-amd64.img \
  /srv/kvm-lab/images/lab01.qcow2 40G

sudo chgrp kvm noble-server-cloudimg-amd64.img lab01.qcow2
sudo chmod 0660 noble-server-cloudimg-amd64.img lab01.qcow2

The 40 GB size is the guest-visible maximum, not an immediate allocation. Monitor the host filesystem because qcow2 cannot protect a VM from an exhausted host volume.

Provision the guest with cloud-init

Generate user data from the administrator’s existing Ed25519 public key. Stripping the optional key comment avoids awkward YAML characters while preserving the complete key.

cd /srv/kvm-lab

key_file="${HOME}/.ssh/id_ed25519.pub"
test -s "$key_file" || {
  echo "Missing SSH public key: $key_file" >&2
  exit 1
}
public_key=$(awk 'NR == 1 { print $1 " " $2 }' "$key_file")

cat > cloud-init/lab01-user-data <<EOF
#cloud-config
hostname: lab01
fqdn: lab01.lab.internal
manage_etc_hosts: true
ssh_pwauth: false
disable_root: true
users:
  - name: ops
    groups:
      - sudo
    shell: /bin/bash
    lock_passwd: true
    sudo:
      - ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - ${public_key}
package_update: true
packages:
  - qemu-guest-agent
runcmd:
  - [systemctl, enable, --now, qemu-guest-agent]
EOF

cat > cloud-init/lab01-meta-data <<'EOF'
instance-id: lab01-001
local-hostname: lab01
EOF

cat > cloud-init/lab01-network-config <<'EOF'
version: 2
ethernets:
  ens3:
    match:
      driver: virtio_net
    set-name: ens3
    addresses:
      - 192.168.50.10/24
    routes:
      - to: default
        via: 192.168.50.1
    nameservers:
      addresses:
        - 192.168.50.1
EOF

cloud-localds \
  --network-config=cloud-init/lab01-network-config \
  seeds/lab01-seed.img \
  cloud-init/lab01-user-data \
  cloud-init/lab01-meta-data

sudo chgrp kvm seeds/lab01-seed.img
sudo chmod 0660 seeds/lab01-seed.img

Cloud-init normally applies a given instance ID only once. If you intentionally rebuild the overlay, change instance-id or clear cloud-init state inside the discarded guest before capturing a template.

Deploy the VM with explicit limits

Create the domain with a fixed two-vCPU and 2 GB ceiling, virtio devices, serial access, and a guest-agent channel. Host CPU passthrough improves feature availability but ties the VM more closely to this processor family, making migration to dissimilar hardware less reliable.

sudo virt-install \
  --connect qemu:///system \
  --name lab01 \
  --import \
  --os-variant ubuntu24.04 \
  --memory 2048,maxmemory=2048 \
  --vcpus 2,maxvcpus=2 \
  --cpu host-passthrough \
  --disk path=/srv/kvm-lab/images/lab01.qcow2,format=qcow2,bus=virtio,cache=none,discard=unmap \
  --disk path=/srv/kvm-lab/seeds/lab01-seed.img,device=cdrom,readonly=on \
  --network bridge=br0,model=virtio,mac=52:54:00:50:00:10 \
  --channel unix,target_type=virtio,name=org.qemu.guest_agent.0 \
  --graphics none \
  --console pty,target_type=serial \
  --noautoconsole

sudo virsh autostart lab01

sudo virsh schedinfo lab01 \
  --set vcpu_period=100000 \
  --set vcpu_quota=100000 \
  --live --config

sudo virsh blkdeviotune lab01 vda \
  --total-bytes-sec 52428800 \
  --live --config

The CPU quota permits roughly one host CPU of aggregate execution across the two virtual CPUs; workloads can still use two-way concurrency in short bursts. The disk rule caps combined throughput at 50 MiB/s. Tune both limits against measured host contention rather than treating these values as universal.

Test, harden, and observe

Wait for cloud-init before judging provisioning. From the host, verify connectivity, the agent, device limits, and autostart state:

ping -c 3 192.168.50.10
ssh [email protected] 'cloud-init status --wait'
ssh [email protected] 'systemctl is-active qemu-guest-agent'

sudo virsh qemu-agent-command lab01 '{"execute":"guest-ping"}'
sudo virsh dominfo lab01
sudo virsh domblklist lab01 --details
sudo virsh schedinfo lab01
sudo virsh blkdeviotune lab01 vda
sudo virsh domstats lab01 --vcpu --balloon --block --interface

Inside the guest, enable a default-deny firewall while preserving SSH from the administrative LAN:

sudo apt install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 192.168.50.0/24 to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose

A host firewall does not automatically protect bridged guest traffic; behavior depends on the host’s bridge netfilter configuration. Apply policy in the guest and, where appropriate, on the physical switch or upstream firewall. Keep libvirt management local unless authenticated remote administration is an explicit requirement.

Use journalctl -u libvirtd for host-side failures, virsh console lab01 for early boot, and /var/log/cloud-init-output.log inside the guest for provisioning errors. Track disk allocation with qemu-img info only while the image is inactive; use libvirt block statistics for a running VM.

After provisioning succeeds, detach the seed from the persistent configuration so future backups do not depend on it:

sudo virsh detach-disk lab01 \
  /srv/kvm-lab/seeds/lab01-seed.img \
  --config

Use snapshots as rollback points

Snapshots are convenient before risky maintenance, but they are not backups: they share the same host, storage, and failure domain. For a clean internal snapshot, stop the guest and wait for libvirt to report shut off.

sudo virsh shutdown lab01
until test "$(sudo virsh domstate lab01)" = "shut off"; do
  sleep 2
done

sudo virsh snapshot-create-as lab01 pre-upgrade \
  --description "Clean checkpoint before package upgrade" \
  --atomic
sudo virsh snapshot-list lab01
sudo virsh start lab01

To roll back, stop the guest cleanly, run sudo virsh snapshot-revert lab01 pre-upgrade, and start it again. Reversion discards all changes made after the checkpoint. Delete obsolete snapshots deliberately because accumulated internal snapshots increase qcow2 metadata and operational complexity.

Create a recoverable offline backup

Save the following script as /srv/kvm-lab/scripts/backup-lab01.sh and make it executable. It requests a clean shutdown, waits for at most two minutes, flattens the backing chain, records the domain XML, checksums the result, and restarts only a VM that was originally running. A failed conversion leaves no valid checksum manifest, so recovery will reject the incomplete directory.

#!/usr/bin/env bash
set -Eeuo pipefail

vm="lab01"
disk="/srv/kvm-lab/images/lab01.qcow2"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
dest="/srv/kvm-lab/backups/${vm}-${stamp}"
restart=0

cleanup() {
  if (( restart )); then
    state=$(sudo virsh domstate "$vm" 2>/dev/null || true)
    if [[ "$state" == "shut off" ]]; then
      sudo virsh start "$vm"
    fi
  fi
}
trap cleanup EXIT

state=$(sudo virsh domstate "$vm")
if [[ "$state" == "running" ]]; then
  restart=1
  sudo virsh shutdown "$vm"

  for ((attempt = 1; attempt <= 60; attempt++)); do
    [[ "$(sudo virsh domstate "$vm")" == "shut off" ]] && break
    sleep 2
  done
fi

[[ "$(sudo virsh domstate "$vm")" == "shut off" ]] || {
  echo "Guest did not shut down; backup aborted" >&2
  exit 1
}

sudo install -d -o root -g root -m 0700 "$dest"
sudo virsh dumpxml "$vm" | sudo tee "$dest/lab01.xml" >/dev/null
sudo qemu-img convert -p -O qcow2 \
  -o compat=1.1,lazy_refcounts=on \
  "$disk" "$dest/lab01.qcow2"
sudo qemu-img check "$dest/lab01.qcow2"

cd "$dest"
sudo sha256sum lab01.xml lab01.qcow2 |
  sudo tee SHA256SUMS >/dev/null

Run it with chmod 0750 /srv/kvm-lab/scripts/backup-lab01.sh followed by the script path. Copy completed backup directories to separate storage with independent retention. The shutdown makes filesystem state clean; databases and external services may still require application-aware backup procedures.

Prove recovery before trusting it

Select a completed backup, verify its manifest, and run qemu-img check and the storage directories first, restore the disk to the same absolute path, then define the saved XML. Do not boot the recovered VM alongside the original because they share an IP address, MAC address, machine identity, and SSH host keys.

Final verification checklist

  • The host owns 192.168.50.2 on br0, not on enp3s0.
  • lab01 reaches the gateway and is reachable only through intended firewall rules.
  • Cloud-init completed without errors, and the guest agent answers through libvirt.
  • Memory, vCPU, CPU quota, and block throughput limits survive a reboot.
  • The VM autostarts, while the host retains enough uncommitted memory and storage.
  • A clean snapshot can be created, listed, reverted, and retired.
  • The latest backup passes both SHA-256 verification and qemu-img check.
  • Recovery has been rehearsed without allowing the original and restored identities onto the LAN together.

A production-grade lab is not defined by how quickly its first VM boots. It is defined by how predictably the tenth VM behaves, how clearly failures present themselves, and how calmly the system can be restored. Bridging, cloud-init, quotas, snapshots, and verified backups turn KVM from a convenient hypervisor into infrastructure you can reason about.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.