Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Build a KVM Virtualization Host on Ubuntu Server

How to Build a KVM Virtualization Host on Ubuntu Server

It usually starts small: one service that needs isolation, one legacy app that cannot share a host, one vendor appliance that “just needs a VM.” Then the second VM arrives. Then backups, monitoring, patch windows, and a security review. Suddenly the “simple box in the corner” is now a platform that other teams depend on.

That is where KVM virtualization on Ubuntu Server becomes important. Not because it is flashy, but because it gives us a controlled, supportable way to run multiple workloads with clear boundaries, predictable networking, and operational discipline. In this guide we will build a pure KVM/libvirt host on Ubuntu, with production-grade defaults: secure access, persistent configuration, verification at every step, and a networking model that scales from home labs to enterprise racks.

Prerequisites and assumptions

Before we touch commands, we need to be explicit about what we are building on and what we are not assuming. These details are what keep a virtualization host stable months later.

  • Operating system: Ubuntu Server (recommended: 22.04 LTS or 24.04 LTS). Commands below are written for modern Ubuntu with systemd and netplan.
  • Install state: A clean or minimally customized server install is preferred. If the host already runs complex networking (custom bridges, VPNs, overlay networks), we should inventory and document it first.
  • Hardware virtualization: CPU must support Intel VT-x/VT-d or AMD-V/AMD-Vi, and virtualization must be enabled in BIOS/UEFI.
  • Storage: Enough local disk for VM images. For production, we should plan separate storage (RAID, SAN/NAS, or dedicated SSD pool) and a backup strategy.
  • Network: At least one NIC. For the cleanest production design, we prefer a dedicated management network and a dedicated VM network, but we can start with one NIC and a bridge.
  • Access: We assume we have SSH access and can run commands with sudo. We will avoid risky one-liners and verify after each major change.
  • Scope: Pure KVM/libvirt on Ubuntu. No third-party virtualization management stacks.

Set a few safe shell defaults for this session

We are about to run administrative commands. Setting a strict shell mode reduces accidental partial failures and makes copy/paste behavior more predictable.

set -euo pipefail

This makes the shell stop on errors, treat unset variables as errors, and fail pipelines if any command fails. If we open a new shell later, we will need to set it again.

Step 1: Confirm hardware virtualization support

Before installing anything, we confirm the CPU and kernel can actually run KVM. This prevents the common situation where everything installs fine but VMs fail to start later.

sudo apt-get update
sudo apt-get install -y cpu-checker
kvm-ok || true

We updated package metadata, installed cpu-checker, and ran kvm-ok. If it reports that KVM acceleration can be used, we are good. If it reports that KVM cannot be used, we should check BIOS/UEFI virtualization settings and confirm we are not inside a nested environment that blocks virtualization.

We also confirm the kernel sees virtualization flags:

egrep -c '(vmx|svm)' /proc/cpuinfo

A non-zero number indicates the CPU exposes virtualization extensions to the OS.

Step 2: Install KVM, libvirt, and the core tooling

Now we install the hypervisor (KVM), the management layer (libvirt), and practical tools for building and inspecting VMs. We keep this set minimal and standard for Ubuntu.

sudo apt-get update
sudo apt-get install -y qemu-kvm libvirt-daemon-system libvirt-clients virtinst bridge-utils ovmf

This installs:

  • qemu-kvm for KVM acceleration and QEMU runtime
  • libvirt-daemon-system and libvirt-clients for service management and CLI tools
  • virtinst for creating VMs from the command line
  • bridge-utils for bridge inspection utilities (netplan still manages the bridge)
  • ovmf for UEFI firmware support (useful for modern guest OSes)

Verify libvirt is running and enabled

We need libvirt to start on boot and be healthy now. This is foundational for everything else.

sudo systemctl enable --now libvirtd
sudo systemctl status --no-pager libvirtd

We enabled and started libvirtd. The status output should show active (running). If it is not running, we should not proceed until we resolve it, because VM lifecycle operations depend on this daemon.

Verify KVM device nodes exist

KVM exposes a device node that QEMU uses for hardware acceleration. If it is missing, VMs will run slowly or fail.

ls -l /dev/kvm || true
sudo kvm-ok || true

If /dev/kvm exists and kvm-ok is positive, the host is ready for accelerated virtualization.

Step 3: Set correct permissions for operational access

In production, we avoid running day-to-day VM operations as root. Libvirt uses a Unix group to grant access to its management socket. We will add our current administrative user to the libvirt and kvm groups.

First we confirm our current username so we do not guess:

whoami

Now we add that user to the required groups:

sudo usermod -aG libvirt,kvm "$(whoami)"

This changes group membership, but it does not affect the current shell session. We must start a new login session for it to apply.

Verify group membership

We confirm the user is now in the right groups. If not, we will not be able to manage VMs without sudo.

id -nG

We should see libvirt and kvm in the output. If we do not, we should log out and back in (or reconnect via SSH) and check again.

Step 4: Choose the networking model and build a production bridge

Networking is where virtualization hosts either stay clean for years or become fragile. Libvirt’s default NAT network is fine for isolated lab VMs, but most real environments need VMs to be first-class citizens on the LAN: reachable, monitored, and governed by the same network controls as physical servers.

We will build a Linux bridge managed by netplan. The physical NIC will be attached to the bridge, and VMs will connect to the bridge. This design is stable, persistent across reboots, and easy to reason about.

Identify the primary network interface

We do not hardcode interface names. We will detect the interface that carries the default route.

EXT_IFACE=$(ip -o -4 route show to default | awk '{print $5}' | head -n1)
echo "Primary interface: ${EXT_IFACE}"

This sets EXT_IFACE to the interface used for outbound traffic (often ens160, enp1s0, etc.). We will use it when building the bridge.

Capture current IP configuration details

Before changing netplan, we need to know whether the host uses DHCP or a static IP. We will print the current IPv4 address and default gateway so we can replicate it on the bridge.

ip -4 addr show dev "${EXT_IFACE}" | sed -n 's/.*inet (.*) brd.*/1/p'
ip route show default

We now have the current address (CIDR form like 192.0.2.10/24) and the default gateway. If the host uses DHCP, we can keep DHCP on the bridge. If it uses static addressing, we must move the static config from the NIC to the bridge.

Implement a netplan bridge (DHCP-based)

If the host currently uses DHCP, this is the safest and most common approach. We will create a bridge named br0, attach the physical NIC, and request DHCP on the bridge. The physical NIC itself will not have an IP.

First we identify the active netplan file so we edit the right place:

ls -1 /etc/netplan

Now we will create a dedicated netplan file for the bridge. This avoids accidental edits to installer-generated files and makes intent clear.

sudo tee /etc/netplan/01-br0.yaml >/dev/null <<EOF
network:
  version: 2
  renderer: networkd
  ethernets:
    ${EXT_IFACE}:
      dhcp4: no
      dhcp6: no
  bridges:
    br0:
      interfaces: [${EXT_IFACE}]
      dhcp4: yes
      dhcp6: no
      parameters:
        stp: false
        forward-delay: 0
EOF

This defines br0 as the L2 bridge and moves DHCP to the bridge. STP is disabled for simplicity; if we are bridging into networks where loops are possible, we should enable STP and design accordingly.

Now we apply the configuration. This can disrupt SSH if we are remote, so we will use netplan’s safe mode first.

sudo netplan try

netplan try applies the change temporarily and asks for confirmation. If connectivity breaks, it will roll back automatically. If everything stays reachable, we confirm.

Verify the bridge is up and the host has an IP on br0

We confirm that the bridge exists, the physical NIC is enslaved, and the IP moved to br0.

ip link show br0
bridge link | sed -n '1,200p'
ip -4 addr show dev br0
ip route show default

We should see br0 in UP state, the physical interface listed as a bridge port, and the host IP assigned to br0. The default route should remain intact.

Static IP variant (when DHCP is not allowed)

If the environment requires static addressing, we should not guess values. We will extract the current address and gateway, then write them into netplan for br0.

First we capture the current IPv4 CIDR and gateway into variables:

HOST_CIDR=$(ip -o -4 addr show dev "${EXT_IFACE}" | awk '{print $4}' | head -n1)
GW4=$(ip route show default | awk '{print $3}' | head -n1)
echo "HOST_CIDR=${HOST_CIDR}"
echo "GW4=${GW4}"

Now we write a static bridge configuration. We also preserve DNS settings by reading systemd-resolved’s current upstream view. If DNS is centrally managed, we should set the correct resolvers explicitly.

DNS1=$(resolvectl dns "${EXT_IFACE}" 2>/dev/null | awk '{print $NF}' | head -n1 || true)
DNS2=$(resolvectl dns "${EXT_IFACE}" 2>/dev/null | awk '{print $(NF-1)}' | head -n1 || true)
echo "DNS1=${DNS1}"
echo "DNS2=${DNS2}"

If DNS1 and DNS2 are empty, we should set them to known-good resolvers for the environment before applying.

sudo tee /etc/netplan/01-br0.yaml >/dev/null <<EOF
network:
  version: 2
  renderer: networkd
  ethernets:
    ${EXT_IFACE}:
      dhcp4: no
      dhcp6: no
  bridges:
    br0:
      interfaces: [${EXT_IFACE}]
      addresses: [${HOST_CIDR}]
      routes:
        - to: default
          via: ${GW4}
      nameservers:
        addresses:
          - ${DNS1}
          - ${DNS2}
      parameters:
        stp: false
        forward-delay: 0
EOF

We have moved the static IP, default route, and DNS to br0. We apply safely:

sudo netplan try

After confirming, we verify the same way as the DHCP case: ip -4 addr show dev br0 and ip route should match expectations.

Step 5: Connect libvirt to the bridge and set sane defaults

Libvirt can attach VMs to different networks. We want a stable, explicit network path: VMs should connect to br0. We will keep libvirt’s default NAT network available (it can be useful for isolated testing), but for production workloads we will attach to the bridge.

Verify libvirt networks and storage pools

Before creating anything, we inspect what libvirt already has.

virsh net-list --all
virsh pool-list --all

This shows existing networks (often default) and storage pools. We are not changing anything yet; we are establishing a baseline.

Create a libvirt network that maps to br0

We will define a libvirt network that forwards directly to the host bridge. This makes VM NIC attachment consistent and avoids manual per-VM bridge wiring.

sudo tee /tmp/br0-net.xml >/dev/null <<'EOF'
<network>
  <name>br0-net</name>
  <forward mode='bridge'/>
  <bridge name='br0'/>
</network>
EOF

sudo virsh net-define /tmp/br0-net.xml
sudo virsh net-autostart br0-net
sudo virsh net-start br0-net

We defined a new libvirt network named br0-net, set it to autostart on boot, and started it immediately. This does not change host networking; it only gives libvirt a named handle to the existing bridge.

Verify the new network is active

We confirm libvirt sees the bridge-backed network and that it is running.

virsh net-list --all
virsh net-info br0-net

The network should show as active and autostart should be yes.

Step 6: Prepare storage for VM images (production-friendly)

VM storage is where performance and recoverability live. Ubuntu/libvirt commonly uses /var/lib/libvirt/images. That is acceptable for small deployments, but in production we often mount dedicated storage there or create a separate pool. For now, we will create a dedicated directory and a libvirt directory pool, which keeps the design clean and easy to migrate later.

Create a dedicated images directory

We will create /srv/libvirt/images with controlled permissions. Libvirt typically runs QEMU as an unprivileged user, so permissions matter.

sudo mkdir -p /srv/libvirt/images
sudo chown -R root:libvirt /srv/libvirt
sudo chmod 2770 /srv/libvirt/images
ls -ld /srv/libvirt/images

This creates the directory, assigns group ownership to libvirt, and sets the setgid bit so new files inherit the group. This helps teams collaborate without opening permissions too widely.

Define a libvirt storage pool

Now we tell libvirt about this directory so VM creation tools can target it consistently.

sudo virsh pool-define-as --name images --type dir --target /srv/libvirt/images
sudo virsh pool-autostart images
sudo virsh pool-start images
virsh pool-info images
virsh pool-list --all

We created a directory-based storage pool named images, enabled autostart, started it, and verified it is active. From here on, we can place VM disks in a known location with predictable ownership.

Step 7: Security baseline for a virtualization host

A virtualization host is a high-value target: compromise the host and every guest is effectively compromised. We will apply a baseline that is realistic for home, professional, and enterprise environments: patching discipline, minimal exposed services, and firewall rules that do not break bridging.

Confirm SSH is the only exposed management service

We start by listing listening sockets. This tells us what the host is actually exposing.

sudo ss -tulpen

We should see SSH (typically port 22) and local-only libvirt sockets. If we see unexpected listeners on 0.0.0.0, we should investigate before proceeding.

Enable UFW with a conservative rule set

On Ubuntu, UFW is a practical baseline firewall. For a KVM host, we generally allow SSH and otherwise deny inbound. Bridged VM traffic is forwarded at L2 and is not “routed” by the host in the same way, but host firewall rules can still affect host services. We will keep it simple: allow SSH, enable UFW, and verify.

First we ensure UFW is installed:

sudo apt-get install -y ufw

Now we allow SSH and enable the firewall:

sudo ufw allow OpenSSH
sudo ufw --force enable
sudo ufw status verbose

This enables a default-deny inbound stance while keeping SSH accessible. If the environment uses a non-standard SSH port, we should adjust the rule before enabling.

Keep libvirt remote management disabled unless explicitly required

Libvirt can be exposed over TCP, but that is not a default we want on a general-purpose host. We will verify that libvirt is not listening on TCP ports.

sudo ss -tulpen | grep -E 'libvirtd|:16509|:16514' || true

If nothing is returned, libvirt is not exposed over TCP, which is the safer baseline. If we do need remote libvirt management in an enterprise, we should implement it intentionally with TLS, strict firewalling, and centralized identity controls.

Step 8: Create a first VM with virt-install (bridge-backed)

Now we validate the whole stack by creating a VM that attaches to br0-net and stores its disk in our images pool directory. We will do this in a way that is repeatable and does not rely on interactive UI tools.

Prepare an installation ISO

We need an ISO stored locally on the host. We will create an ISO directory and place the ISO there. Since environments vary, we will not embed a download URL. Instead, we will create the directory and verify the file is present.

sudo mkdir -p /srv/libvirt/iso
sudo chown -R root:libvirt /srv/libvirt/iso
sudo chmod 2770 /srv/libvirt/iso
ls -ld /srv/libvirt/iso

We now have a controlled location for ISO files. Next, we verify the ISO exists by listing the directory:

ls -lh /srv/libvirt/iso

If the ISO is not present, we should upload it via SCP/SFTP into /srv/libvirt/iso before continuing.

Create the VM using detected bridge network

We will create a VM named vm01 with a 40G disk and attach it to br0-net. We will also use UEFI firmware support via OVMF, which is common for modern guests.

First we pick the ISO path safely by selecting the first ISO in the directory. This keeps the command copy/paste-safe without hardcoding a filename.

ISO_PATH=$(ls -1 /srv/libvirt/iso/*.iso 2>/dev/null | head -n1 || true)
echo "ISO_PATH=${ISO_PATH}"

If ISO_PATH is empty, we do not proceed. We place an ISO in /srv/libvirt/iso and re-run the command.

Now we create the VM:

sudo virt-install 
  --name vm01 
  --memory 4096 
  --vcpus 2 
  --cpu host 
  --disk path=/srv/libvirt/images/vm01.qcow2,size=40,format=qcow2,bus=virtio 
  --network network=br0-net,model=virtio 
  --os-variant detect=on,require=off 
  --cdrom "${ISO_PATH}" 
  --boot uefi 
  --graphics none 
  --console pty,target_type=serial

This defines and starts the VM. We used:

  • --cpu host for better performance and compatibility with modern guests
  • virtio for disk and NIC for performance
  • qcow2 for flexible storage (snapshots, sparse allocation)
  • --graphics none and serial console for server-style installs over SSH

Verify the VM is running and attached to the right network

We confirm the VM exists, is running, and has a NIC connected via libvirt.

virsh list --all
virsh dominfo vm01
virsh domiflist vm01

We should see vm01 in the list, its state as running (during install), and an interface connected to br0-net. If the VM is shut off, we check logs in the troubleshooting section.

Connect to the VM console

For headless installs, the serial console is our lifeline. We will attach to it to confirm the installer is visible.

sudo virsh console vm01

If we need to exit the console, we use the standard escape sequence: Ctrl + ]. Seeing the installer confirms that CPU, storage, libvirt, and console wiring are all functioning.

Step 9: Operational verification and reboot persistence

A virtualization host is only “done” when it survives reboots cleanly. We will verify that the bridge, libvirt, and our storage/network definitions persist.

Verify autostart settings

We confirm that libvirt, the network, and the storage pool are configured to come back after a reboot.

systemctl is-enabled libvirtd
virsh net-autostart --disable default 2>/dev/null || true
virsh net-info br0-net
virsh pool-info images

We confirmed libvirt is enabled. We also attempted to disable autostart for the default NAT network (optional); keeping it disabled reduces surprises in environments where we want only bridged networking. The key checks are that br0-net and images show Autostart: yes.

Verify the bridge is managed by netplan and present

We confirm the netplan file exists and the bridge is still the active interface.

sudo netplan get | sed -n '1,200p'
ip -4 addr show dev br0
ip route show default

This confirms that the bridge configuration is part of the system’s declarative network state and that the host’s IP and routing are correct.

Troubleshooting

Symptom: kvm-ok says acceleration cannot be used

  • Likely causes: Virtualization disabled in BIOS/UEFI; running inside a VM without nested virtualization; CPU does not support VT-x/AMD-V.
  • Fix: Enable Intel VT-x/VT-d or AMD-V/AMD-Vi in firmware; if nested, enable nested virtualization on the parent hypervisor; confirm egrep -c '(vmx|svm)' /proc/cpuinfo is non-zero.

Symptom: VM starts but runs extremely slowly

  • Likely causes: KVM not in use; QEMU falling back to software emulation.
  • Fix: Confirm /dev/kvm exists and permissions allow access. Check group membership: id -nG. Verify the VM uses --cpu host and virtio devices.

Symptom: After applying netplan, SSH disconnects and does not return

  • Likely causes: Bridge misconfiguration; wrong interface name; static IP/gateway not moved to br0.
  • Fix: Use out-of-band console (iDRAC/iLO/physical) and run sudo netplan generate and sudo netplan apply after correcting /etc/netplan/01-br0.yaml. Prefer sudo netplan try for future changes to ensure rollback.

Symptom: VM has no network connectivity on the LAN

  • Likely causes: VM attached to the wrong libvirt network; bridge not connected to the correct physical NIC; upstream switch port security (MAC limits) blocking multiple MACs; VLAN expectations not met.
  • Fix: Check attachment: virsh domiflist vm01. Confirm bridge ports: bridge link. If the switch limits MAC addresses, adjust port security or use a dedicated trunk/access configuration designed for virtualization.

Symptom: virsh commands fail with permission denied for a non-root user

  • Likely causes: User not in libvirt group; session not refreshed after group change.
  • Fix: Confirm groups: id -nG. If missing, re-run sudo usermod -aG libvirt,kvm "$(whoami)" and then log out and back in.

Symptom: VM fails to start with storage permission errors

  • Likely causes: Incorrect ownership/permissions on /srv/libvirt/images; SELinux/AppArmor constraints (Ubuntu typically uses AppArmor); disk path not accessible to libvirt/QEMU.
  • Fix: Verify directory permissions: ls -ld /srv/libvirt/images. Ensure group is libvirt and mode allows group write where needed. Check VM logs: sudo journalctl -u libvirtd --no-pager -n 200 and sudo virsh domstate vm01.

Common mistakes

Mistake: Editing the wrong netplan file and leaving conflicting configs

Symptom: Bridge exists sometimes, disappears after reboot, or the host gets two IPs.

Fix: Keep a single authoritative bridge config file (like /etc/netplan/01-br0.yaml) and remove or neutralize conflicting definitions. Validate with sudo netplan get and then apply with sudo netplan try.

Mistake: Attaching VMs to the default NAT network unintentionally

Symptom: VM gets an IP like 192.168.122.x and is not reachable from the LAN.

Fix: Attach to the bridge-backed network: verify with virsh domiflist <vm>. Use --network network=br0-net during VM creation.

Mistake: Assuming the switch will accept multiple MAC addresses on a single port

Symptom: Host stays online, but VMs cannot pass traffic, or only one VM works at a time.

Fix: Review switch port security and MAC limits. For enterprise networks, explicitly configure the port for virtualization use (appropriate MAC limits, VLAN mode, and monitoring).

Mistake: Forgetting reboot persistence checks

Symptom: After reboot, VMs cannot start because the network or storage pool is inactive.

Fix: Ensure autostart is enabled: virsh net-autostart br0-net and virsh pool-autostart images. Confirm systemctl is-enabled libvirtd is enabled.

How do we at NIILAA look at this

This setup is not impressive because it is complex. It is impressive because it is controlled. Every component is intentional. Every configuration has a reason. This is how infrastructure should scale — quietly, predictably, and without drama.

At NIILAA, we help organizations design, deploy, secure, and maintain KVM/libvirt platforms that hold up under real operational pressure: clean network architecture, hardened host baselines, storage and backup strategy, access control, and lifecycle processes that keep VM fleets stable over time.

Website: https://www.niilaa.com
Email: [email protected]
LinkedIn: https://www.linkedin.com/company/niilaa
Facebook: https://www.facebook.com/niilaa.llc

Leave A Comment

All fields marked with an asterisk (*) are required