How to Run Virtual Machines on Kubernetes with KubeVirt and K3s
Running KubeVirt K3s virtual machines Kubernetes workloads with containers lets you keep a legacy Windows or Linux dependency alive without maintaining a separate hypervisor stack. This guide shows you how to install KubeVirt on a K3s cluster, launch a Linux VM from a cloud image, set up storage and networking, and understand live migration and backup limits.
Table of Contents
Why Run Virtual Machines on Kubernetes with KubeVirt and K3s
Most teams don’t need KubeVirt K3s virtual machines Kubernetes to replace Proxmox or VMware; they use it for one legacy app, licensed tool, or Windows-only program that can’t be turned into a container, but it needs to sit next to all their other apps that already run on Kubernetes.
Without KubeVirt, you’d need two separate systems, one for containers, one for VMs, each with its own monitoring, access rules, and networking to manage. KubeVirt removes that split. The VM runs inside the same Kubernetes cluster, managed with the same kubectl commands, GitOps pipelines, and network policies as everything else.
This is the correct mental model for KubeVirt K3s virtual machines Kubernetes:
Think of KubeVirt as a bridge that lets VMs and containers run together on one platform, not as a replacement for full-scale virtualization.
If you need to run many VMs at datacenter scale, a dedicated Proxmox host is still the better choice.
Prerequisites for KubeVirt K3s Virtual Machines Kubernetes
Before deploying KubeVirt K3s virtual machines Kubernetes, confirm the following on every node that will schedule VM workloads:
- A running K3s cluster, single-node or multi-node, on Ubuntu 22.04 or 24.04. If you haven’t set it up yet, check our K3s single-node install guide.
- A physical server or a VM with nested virtualization turned on, where hardware virtualization such as Intel VT-x or AMD-V is enabled and visible to the operating system.
kubectlis configured with the cluster.- At least 4 vCPU and 8 GB RAM free on nodes that will run VMs.
- A default StorageClass. K3s ships
local-path-provisionerby default, which works for testing but isn’t ideal for live migration.
Step 1. Verify Hardware Virtualization
KubeVirt K3s virtual machines Kubernetes require KVM acceleration for acceptable performance. Check on each node:
apt install qemu-kvm libvirt-daemon-system -y
virt-host-validate qemu
You should see PASS next to both “Checking for hardware virtualization” and “Checking if device /dev/kvm exists“.
If you need an infrastructure to run KubeVirt K3s virtual machines Kubernetes workloads, choose a reliable dedicated server with confirmed VT-x or AMD-V support. Most standard cloud VPS plans don’t support nested virtualization properly, or disable it entirely. If that happens, KubeVirt quietly falls back to slow software emulation instead of telling you there’s a problem, so your VMs will run, just much slower than expected.
Step 2. Install K3s with Virtualization Support
If you haven’t installed K3s yet, install it using the stable channel:
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=stable sh -s - server \
--write-kubeconfig-mode 644
Confirm the node is ready:
sudo k3s kubectl get nodes
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodes -o wide
Load the KVM kernel modules the KubeVirt K3s virtual machines Kubernetes stack depends on, and make them persistent across reboots:
sudo modprobe kvm vhost_net
echo -e "kvm\nvhost_net" | sudo tee -a /etc/modules-load.d/kubevirt.conf
If your nodes run containerd, which is K3s’ default, no additional container runtime configuration is required. Containerd is fully supported by KubeVirt.
Step 3. Deploy KubeVirt on the Cluster
At this point, pull the latest KubeVirt release tag and apply the operator and custom resource:
export RELEASE=$(curl -s https://storage.googleapis.com/kubevirt-prow/release/kubevirt/kubevirt/stable.txt)
kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/${RELEASE}/kubevirt-operator.yaml
kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/${RELEASE}/kubevirt-cr.yaml
Wait for the KubeVirt K3s virtual machines Kubernetes system to report ready:
kubectl -n kubevirt wait kv kubevirt --for condition=Available --timeout=300s
kubectl get pods -n kubevirt
You should see the following pods in Running state:
virt-api, virt-controller, virt-handler, and virt-operator
If virt-handler crashes with the following error, it’s an AppArmor profile blocking the QEMU binary:
libvirt: error : cannot execute binary /usr/libexec/qemu-kvm
You can remove the host’s libvirtd package or add an exec rule to /etc/apparmor.d/usr.sbin.libvirtd.
Enable Software Emulation as a Fallback
If a node truly has no hardware virtualization support, KubeVirt can still run VMs using software emulation instead, but it will be slower. To enable it:
kubectl patch kubevirt kubevirt -n kubevirt --type merge \
--patch '{"spec":{"configuration":{"developerConfiguration":{"useEmulation":true}}}}'
Note: Only use this for testing. Production KubeVirt K3s virtual machines Kubernetes deployments should always run on KVM-capable hardware.
Step 4. Install virtctl
virtctl is the CLI extension for VM lifecycle actions, including start, stop, console, VNC, and migrate, that kubectl doesn’t natively support. Install the binary matching the KubeVirt release you deployed:
ARCH=$(uname -s | tr A-Z a-z)-$(uname -m | sed 's/x86_64/amd64/')
curl -L -o virtctl \
"https://github.com/kubevirt/kubevirt/releases/download/${RELEASE}/virtctl-${RELEASE}-${ARCH}"
chmod +x virtctl
sudo install virtctl /usr/local/bin
virtctl version
Every KubeVirt K3s virtual machines Kubernetes operation from here, such as console access, VM start/stop, port-forwarding, and migration triggers, goes through this binary.
Step 5. Install CDI for Cloud Image Imports
The Containerized Data Importer (CDI) turns a cloud image URL into a bootable PVC via a DataVolume object. This is how you’ll get a real Ubuntu or Fedora disk into your KubeVirt K3s virtual machines Kubernetes cluster instead of building a disk from scratch.
Install the latest CDI release:
export CDI_VERSION=$(basename $(curl -s -w %{redirect_url} \
https://github.com/kubevirt/containerized-data-importer/releases/latest))
kubectl create -f https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VERSION}/cdi-operator.yaml
kubectl create -f https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VERSION}/cdi-cr.yaml
kubectl get cdi cdi -n cdi
Wait until the CR’s Phase shows Deployed.
Step 6. Create First Linux VM from a Cloud Image
This is the core workflow for KubeVirt K3s virtual machines Kubernetes. You should define a DataVolume sourced from a public cloud image, then a VirtualMachine object that boots from it.
On your K3s server, create the file with a text editor:
nano linux-vm.yaml
Add:
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
name: ubuntu-cloud-disk
namespace: default
spec:
source:
http:
url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
storage:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: legacy-linux-vm
namespace: default
spec:
running: true
template:
metadata:
labels:
kubevirt.io/domain: legacy-linux-vm
spec:
domain:
cpu:
cores: 2
resources:
requests:
memory: 4Gi
devices:
disks:
- name: rootdisk
disk:
bus: virtio
- name: cloudinitdisk
disk:
bus: virtio
interfaces:
- name: default
masquerade: {}
networks:
- name: default
pod: {}
volumes:
- name: rootdisk
dataVolume:
name: ubuntu-cloud-disk
- name: cloudinitdisk
cloudInitNoCloud:
userData: |
#cloud-config
user: ubuntu
password: changeme
chpasswd: { expire: False }
ssh_pwauth: True
Apply it to the cluster and watch the import progress:
kubectl apply -f linux-vm.yaml
kubectl get datavolume ubuntu-cloud-disk -w
kubectl get vmi legacy-linux-vm
The cpu.cores and memory values you set here work just like normal Kubernetes resource requests. That means Kubernetes treats the VM the same way it treats any container pod when deciding where to place it on your nodes.
This is one of the biggest advantages over a standalone hypervisor: you get the same scheduling and resource management for VMs that you already have for containers.
Step 7. Storage Configuration for VMs
The VM’s root disk uses regular Kubernetes storage. Any StorageClass with ReadWriteOnce works fine, or ReadWriteMany if you want to migrate the VM later.
K3s comes with a default storage option called local-path-provisioner. It’s fine for testing on a single node, but it locks the disk to that one node, so the VM can’t move. If you’re running multiple nodes and want to migrate VMs between them, use network storage instead, like Longhorn, Rook-Ceph, or an NFS-based option.
On your K3s server, create a new file:
nano extra-data-disk.yaml
Add:
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
name: extra-data-disk
spec:
source:
blank: {}
storage:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
Apply it:
kubectl apply -f extra-data-disk.yaml
This creates a second, empty 10Gi disk. To actually attach it to your VM, add it to the volumes and disks sections of your VM’s YAML file, then reapply that file.
Step 8. Connect to VMs Using virtctl
By default, the VM above uses masquerade pod networking, giving it outbound connectivity through the pod’s IP without extra CNI plugins, which is sufficient for most KubeVirt K3s virtual machines Kubernetes single-VM scenarios.
For SSH or admin access, expose the VM as a Service:
virtctl expose vmi legacy-linux-vm --name=legacy-vm-ssh --port=2222 --target-port=22 --type=NodePort
kubectl get svc legacy-vm-ssh
Or skip the Service entirely and port-forward straight through virtctl:
virtctl port-forward legacy-linux-vm 2222:22
ssh -p 2222 ubuntu@localhost
If you need a console instead of SSH, you can use:
virtctl console legacy-linux-vm
If you want to connect the VM to more than one isolated network, like putting it directly on a VLAN, you’ll need to install Multus CNI separately; KubeVirt K3s virtual machines Kubernetes don’t ship a multi-network plugin by default.
What You Need for Live Migration
Live migration lets a running VM move between nodes without downtime, but it only works if all of these conditions are met simultaneously:
- The VM’s disk must be on shared and network storage,
ReadWriteManyaccess mode. Local-path PVCs cannot migrate. - All nodes must run compatible CPU models, or the VM must request a portable CPU model instead of
host-passthrough. - The
LiveMigrationfeature must be enabled in the KubeVirt CR, which is enabled by default in current releases. - Enough free CPU and memory space must exist on a target node to receive the VM.
Run a migration manually to test the setup:
virtctl migrate legacy-linux-vm
kubectl get vmim
Note: If your cluster is single-node, live migration simply isn’t possible, no matter how you configure it. Keep it in mind before promising anyone high availability for your VMs.
VM Resource Requests and Backup Boundaries
Unlike containers, VMs need their memory request set close to their memory limit. With containers, you can often get away with requesting less than the limit, since only one process might get killed if memory runs out. With a VM, doing the same thing risks crashing the entire guest operating system, not just one process.
Open the Linux VM YAML file:
nano linux-vm.yaml
Under the resources block, set requests and limits close together for predictable VM behavior:
resources:
requests:
memory: 4Gi
cpu: "2"
limits:
memory: 4Gi
cpu: "2"
On backups, tools like Velero can back up the storage disk behind your VM, but that’s not the same as a proper VM backup. They just copy the raw disk data; they don’t coordinate with the VM first to make sure everything is in a safe state before the backup happens.
Hypervisor tools like Proxmox Backup Server do this coordination by talking to an agent inside the VM. Without that, your backup could capture the disk mid-write, which risks corruption if you ever need to restore it.
Choose Between KubeVirt and a Dedicated Hypervisor
KubeVirt isn’t the right tool for every situation. The table below gives a quick way to check if your use case fits, comparing when KubeVirt on K3s makes sense and when a dedicated hypervisor like Proxmox is the smarter pick.
| Scenario | Better Fit |
|---|---|
| One legacy Windows/Linux app alongside a container platform | KubeVirt K3s virtual machines Kubernetes |
| Dozens of general-purpose VMs, minimal container workloads | Dedicated Proxmox host |
| Need GitOps-managed VM lifecycle alongside microservices | KubeVirt K3s virtual machines Kubernetes |
| Heavy reliance on hypervisor-native backup/snapshot tooling | Proxmox / VMware |
| Multi-node cluster with shared storage already in place | KubeVirt K3s virtual machines Kubernetes (migration-ready) |
Conclusion
KubeVirt lets your K3s cluster run a few VMs alongside your containers, using the same kubectl tools you already know; no need to set up a separate hypervisor just for one legacy app. It works best when your goal is keeping everything in one place, not running lots of VMs. For large-scale VM needs, a dedicated Proxmox host is still the better and more backup-friendly choice.
We hope you enjoy this guide. For more information, check the official KubeVirt user guide.
FAQs
Does KubeVirt replace Proxmox or VMware?
No. It’s built for running a few VMs next to your containers on the same cluster, not for large-scale virtualization.
Can KubeVirt run without hardware virtualization?
Yes, via software emulation, but performance is too slow for production use.
Can I live-migrate a VM on a single-node K3s cluster?
No. Live migration requires at least two nodes and shared storage.