ZK

kvmcli

in progress 13 v1.0.0-alpha
Go Virtualization KVM Qemu SQL

Introducing kvmcli: Terraform-Like Infrastructure for Your KVM Homelab

If you've ever managed KVM virtual machines by hand -- running virsh commands one by one, and losing track of which VMs live on which network -- you know it gets painful fast.

I built kvmcli to fix that: a declarative, Infrastructure-as-Code CLI tool that brings the Terraform/Kubernetes workflow to your local KVM hypervisor.

The Problem

libvirt is powerful, but its interface is not intuitive. Want to spin up a VM? You need to do that manually through a GUI like virt-manager, or worse, Write 80+ lines of XML.

Want a network with DHCP and static IPs? More of that XML.

Tools like Terraform can manage KVM through providers, but they're heavyweight for a homelab. I wanted something purpose-built -- something that feels native to KVM but thinks in declarations, not commands.

The Idea

kvmcli lets you describe your entire virtual infrastructure in HCL (HashiCorp Configuration Language) files. You declare what you want -- networks, stores, VMs -- and kvmcli figures out the rest. It talks directly to libvirt, tracks state in a local SQLite database, and supports the kind of cross-referencing you'd expect from a real IaC tool.

How It Works

First, Define a Store

Everything starts with a store -- it tells kvmcli where your base images live and what OS images are available:

store "homelab" {
  namespace = "homelab"
  backend   = "local"

  paths {
    artifacts = "/home/user/homelab/artifacts"
    images    = "/home/user/homelab/images"
  }

  image "rocky-9.5" {
    display    = "Rocky Linux 9.5"
    version    = "9.5"
    os_profile = "https://rockylinux.org/rocky/9"
    file       = "rocky/rocky-9-5-base-image.qcow2"
    size       = "2.6G"
    checksum   = "sha256:eedbdc2875c32c7f00e70fc861edef48587c7cbfd106885af80bdf434543820b"
  }

  image "rocky-10.1" {
    display    = "Rocky Linux 10.1"
    version    = "10"
    file       = "rocky/rocky-10-1-base-image.qcow2"
    size       = "520M"
  }
}

Each image block registers a base QCOW2 image. When you create a VM, kvmcli creates a copy-on-write overlay on top of it -- your base image stays untouched.

Define a Network

Networks are first-class citizens. Here's a NAT network with DHCP:

network "kubernetes" {
  namespace  = "infra"
  netaddress = "10.0.0.0"
  netmask    = "255.255.255.0"
  bridge     = "br-kubernetes"
  mode       = "nat"
  autostart  = true

  dhcp {
    start = "10.0.0.2"
    end   = "10.0.0.254"
  }

  labels = { environment = "k8s" }
}

kvmcli translates this into libvirt network XML, defines it, and optionally starts it on boot.

Define VMs

Now the main part. VMs reference stores and networks directly using HCL expressions:

vm "reverse-proxy" {
  image     = "rocky-10.1"
  namespace = "infra"
  cpu       = 3
  memory    = 4096
  disk      = "20G"
  store     = data.store.homelab
  network   = network.poc
  ip        = "10.0.0.2"

  labels = {
    role        = "reverse-proxy"
    environment = "poc"
  }
}

That store = data.store.homelab line? That's a data source -- a reference to a store defined in a different HCL file that's already been created. This lets you share resources across manifests without redefining them.

And network = network.poc? That references the network block defined in the same file. kvmcli resolves these references at evaluation time, so your configs stay DRY.

Apply It

# Create everything
kvmcli create -f infrastructure.hcl

# Check what's running
kvmcli get vm
kvmcli get net
kvmcli get store

# Start/stop individual VMs
kvmcli start vm reverse-proxy
kvmcli stop vm reverse-proxy

# Tear it all down
kvmcli delete -f infrastructure.hcl

Real-World Example: A Kubernetes Cluster

Let's say you want a local Kubernetes cluster with an admin/load-balancer node, control plane nodes, and workers.

data "store" "homelab" {}

network "kubernetes" {
  namespace  = "infra"
  netaddress = "10.0.0.0"
  netmask    = "255.255.255.0"
  bridge     = "br-kubernetes"
  mode       = "nat"
  autostart  = true

  dhcp {
    start = "10.0.0.2"
    end   = "10.0.0.254"
  }
}


vm "admin" {
  image     = "rocky-10.1"
  namespace = "k8s"
  cpu       = 1
  memory    = 2048
  disk      = "20G"
  store     = data.store.homelab
  network   = network.kubernetes
  ip        = "10.0.0.10"
  labels    = { role = "admin-lb" }
}

vm "master1" {
  image     = "rocky-9.5"
  namespace = "k8s"
  cpu       = 2
  memory    = 2048
  disk      = "20G"
  store     = data.store.homelab
  network   = network.kubernetes
  ip        = "10.0.0.11"
  labels    = { role = "master" }
}

/* master2, master3, worker1, worker2, worker3 ... same pattern */

One kvmcli create -f k8s.hcl and you have an entire cluster running, each VM with its own static IP, all on the same isolated network. One kvmcli delete -f k8s.hcl and it's gone -- disks, network definitions, database records, everything cleaned up.

Global Defaults

The main goal of this file is to define default values like cpu = 2 and memory = 2048, these values will be used unless they are specifically defined in the manifest files. This config file is written in TOML:

# ~/.config/kvmcli/config.toml

[vm]
cpu = 2
memory = "2GiB"
disk = "20GiB"
namespace = "default"

[domain]
machine = "q35"
arch = "x86_64"

[disk]
bus = "virtio"
format = "qcow2"

[graphics]
type = "vnc"
listen = "0.0.0.0"
autoport = true

[machine_aliases]
q35 = "pc-q35-9.2"

Configs are merged with a clear precedence chain: built-in defaults < /etc/kvmcli/kvmcli.toml < ~/.config/kvmcli/config.toml < ./kvmcli.toml. System-wide defaults for your fleet, personal overrides for your workstation.

Under the Hood

A few design decisions I'm happy with:

  • State tracking via SQLite -- every created resource is recorded in a local database. This means kvmcli always knows what it manages, prevents duplicates via unique constraints, and enables operations like kvmcli delete --all.

  • QCOW2 overlays -- VMs never touch the base image. Each VM gets a copy-on-write overlay created by qemu-img. Destroy the VM, the overlay is deleted, the base image remains pristine, also this ensures blazingly fast provisioning for my VMs/clusters.

Getting Started

git clone https://github.com/kebairia/kvmcli.git
cd kvmcli
make build
sudo mv kvmcli /usr/local/bin/

Prerequisites: a Linux machine with KVM/QEMU enabled and libvirt running. That's it.

What's Next

kvmcli is still evolving. Cluster-level start/stop ordering, snapshots are on the roadmap. But the core workflow -- declare, create, manage, destroy -- is solid and something I use daily in my own homelab.

If you're running a homelab and want the Terraform experience without the Terraform overhead, give kvmcli a try.