- Go 98.8%
- Nix 1%
- Makefile 0.2%
| cmd | ||
| examples | ||
| pkg | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| Makefile | ||
| README.md | ||
svm
Manage QEMU virtual machines from a single YAML file, in the style of docker-compose.
Dependencies
Runtime tools invoked via exec: qemu-system-x86_64, qemu-img, dnsmasq, iptables, passt.
Build
Requires Go 1.25+.
make
Install to a prefix:
make install PREFIX=~/.local
Or via Nix:
nix build
Usage
svm <command> [-f config] [-v] [--force]
| Flag | Default | Description |
|---|---|---|
-f |
auto-discovered | Path to config file |
-v |
off | Enable debug logging |
--force |
off | Skip confirmation prompts (e.g. disk rebuild on image URL change) |
When -f is omitted svm scans running VMs and loads the config from the first one it finds.
Commands
| Command | Description |
|---|---|
build |
Download base images, create qcow2 disks, generate cloud-init ISOs |
up |
Build then start all VMs (sets up networking if configured) |
down [--graceful] |
Stop all running VMs in parallel |
apply [--graceful] |
Start stopped VMs or restart VMs whose config changed |
prune [-s] [config-entry] |
Stop VMs, tear down networking, remove all state files |
ps |
Show VM status, IP address, password, and last snapshot |
logs |
Stream serial output of all running VMs |
show <vm> |
Print the stored state of a VM as JSON |
configs |
List all applied configurations and the VMs that reference them |
console [vm] |
Connect to a VM's serial console — exit with Ctrl-] |
ssh [vm] |
Open an SSH session to a VM, retrying until reachable (30 s timeout) |
snapshot [vm] |
Create a timestamped internal checkpoint of a VM |
snapshots [vm] |
List all internal checkpoints, optionally filtered to one VM |
restore [vm] <snap> |
Restore a VM to a named checkpoint |
completion |
Install bash completion script |
Fuzzy VM name matching — all commands that accept a VM name resolve it with fuzzy search when an exact match is not found.
down and apply stop all VMs in parallel. Pass --graceful to send an ACPI shutdown signal and wait up to 30 s for a clean OS shutdown before killing the process. Snapshot and restore always use graceful shutdown automatically.
prune accepts an optional config entry name (from svm configs) to prune only one configuration. Pass -s / --snapshots to also delete all snapshots for the pruned VMs.
Quick start
$EDITOR vm.yaml # create config
svm up # bring VMs up
svm ps # check status and passwords
svm ssh vm1 # SSH in (retries until the VM is reachable)
svm logs # stream serial output from all running VMs
svm console vm1 # attach to serial console (Ctrl-] to exit)
svm snapshot vm1 # checkpoint before changes
svm snapshots # list all checkpoints
svm restore vm1 <snap> # roll back if needed
svm down # stop all VMs in parallel
svm down --graceful # stop with clean OS shutdown
svm prune # remove all state
Configuration
vm.yaml has the following top-level sections.
vars
The entire config file is rendered as a Go template before it is parsed as YAML. The vars: section declares user-defined variables that are available everywhere in the file — image URLs, disk sizes, network addresses, cloud-init blocks, and so on.
vars:
app_name: myapp
environment: staging
debian_version: 12
data_disk_size: 50G
Reference a variable with {{ .var_name }}. All Sprig template functions are available.
images:
debian: https://cloud.debian.org/images/cloud/.../debian-{{ .debian_version }}-generic-amd64.qcow2
vms:
- name: db
image: debian
disks:
- size: {{ .data_disk_size }}
cloud-init:
user-data:
runcmd:
- echo "app={{ .app_name | upper }} env={{ .environment }}" > /etc/motd
Four built-in variables are always available without being declared in vars::
| Variable | Resolved | Value |
|---|---|---|
{{.USER}} |
config load | Current Unix username (respects SUDO_USER) |
{{.PUBLIC_KEY}} |
config load | First SSH public key found in ~/.ssh/ |
{{.NAME}} |
cloud-init build | VM name (per-VM, resolved during cloud-init generation) |
{{.PASSWD}} |
cloud-init build | SHA-512 password hash derived from the VM name |
NAME and PASSWD are only resolved during cloud-init generation, so they pass through the first template pass unchanged and can be safely used in cloud-init blocks. Referencing an undefined variable is a hard error (missingkey=error).
cloud-init.user-data
A cloud-init #cloud-config block applied to every VM that uses a cloud image. All built-in variables and user-defined vars are available as template placeholders.
cloud-init:
user-data:
hostname: "{{.NAME}}"
fqdn: "{{.NAME}}.{{ .app_name }}.local"
users:
- name: "{{.USER}}"
passwd: "{{.PASSWD}}"
ssh_authorized_keys:
- "{{.PUBLIC_KEY}}"
Each VM can also declare its own cloud-init.user-data block that is merged on top of the root one (maps merge recursively, sequences like packages and runcmd are appended, scalars use the VM value).
A cloud-init block is required for cloud-image VMs — without it no user is created and SSH access is not configured. An SSH public key must exist in ~/.ssh/ at build time; svm build will fail if none is found.
images
A named map of cloud image URLs. VMs reference entries by key with the image: field.
images:
debian12: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2
almalinux9: https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2
If the image_url of a running VM changes, svm warns and asks for confirmation before erasing the primary disk and re-cloning. Use --force to skip the prompt.
iso
A named map of ISO URLs for bare-metal installs (svm up boots the VM from the ISO).
iso:
nixos: https://channels.nixos.org/nixos-25.11/latest-nixos-minimal-x86_64-linux.iso
talos: https://github.com/siderolabs/talos/releases/download/v1.13.2/metal-amd64.iso
net
The net: section is a map of named bridges. Each key becomes the Linux bridge interface name.
Bridged (requires root for bridge/TAP/iptables):
net:
svm0:
dhcp:
enabled: true
address: 192.168.144.1/24
start: 192.168.144.100
end: 192.168.144.200
dns:
- 1.1.1.1
nat:
enabled: true
When dhcp.enabled is true svm creates the bridge, assigns the address, and starts dnsmasq. When nat.enabled is true it enables IP forwarding and adds a POSTROUTING MASQUERADE rule.
Multiple bridges are supported. When more than one bridge is defined each VM must declare which one it belongs to with bridge: <name>.
Network configuration changes are detected by hash. If the bridge config changes, svm tears down and re-creates the affected bridges on the next apply or up.
vms
vms:
- name: vm1
image: debian12 # key from images:, or use image_url: <full url>
memory: 2048 # MiB
cpu: 2
display: none # passed to -display; default "none" (use "gtk" for graphical installs)
bridge: svm0 # required when net: has more than one bridge
disks: # extra data disks (disk0 is always the base image)
- size: 20G
- size: 50G
bindings: # host directories mounted into the VM via 9p
- host: /home/user/data
guest: data # 9p mount tag inside the VM
qemu_args: [] # extra arguments appended to qemu-system-x86_64
cloud-init:
user-data: # merged on top of the root cloud-init block
packages:
- nginx
runcmd:
- systemctl enable --now nginx
Passt mode (rootless — one passt daemon per VM, no bridge or root needed):
vms:
- name: vm1
image: debian12
mode: passt
passt:
address: 192.168.127.2
dns:
- 1.1.1.1
memory: 2048
cpu: 2
For ISO-based installs use iso: instead of image:. Set display: gtk to interact with the installer.
vms:
- name: nixos
iso: nixos
display: gtk
memory: 2048
cpu: 2
disks:
- size: 50G
Snapshots
Snapshots are stored as internal qcow2 checkpoints inside the VM disk — no separate files are created.
| Situation | Mechanism | Notes |
|---|---|---|
VM running — snapshot |
QMP savevm |
Full checkpoint: disk + RAM + CPU state |
VM stopped — snapshot |
qemu-img snapshot -c |
Disk state only |
VM running — restore |
QMP loadvm |
Restored in-place, no VM restart required |
VM stopped — restore |
qemu-img snapshot -a |
Applied offline |
Snapshot names are timestamps in YYYYMMDD_HHMMSS format. Use svm snapshots [vm] to list them and svm restore [vm] <name> to apply one.
State
All runtime state lives under ~/.local/state/vm/:
~/.local/state/vm/
├── images/ # cached base images (downloaded once)
├── iso/ # cached ISO images
├── networks/<bridge>/
│ └── config.hash # hash of last-applied bridge config
└── machines/<name>/
├── vda.qcow2 # copy-on-write overlay disk (internal snapshots stored here)
├── vdb.qcow2 # extra disks (if configured)
├── cloudinit.iso # cloud-init NoCloud ISO (label: cidata)
├── serial.sock # QEMU serial socket
├── qmp.sock # QEMU Machine Protocol socket
├── pid # QEMU process ID
├── passt.sock # passt network socket (passt mode only)
├── passt.pid # passt process ID (passt mode only)
├── config.hash # hash of last-applied VM config
├── state.json # last-applied VM config (used for image URL change detection)
└── config.path # absolute path to vm.yaml (used for auto-discovery)
Bash completion
svm completion
Installs the completion script to $XDG_DATA_HOME/bash-completion/completions/svm (defaults to ~/.local/share/bash-completion/completions/svm). Most distributions pick this up automatically on the next shell session.
Examples
See examples/ for ready-to-use configs:
| File | Description |
|---|---|
debian.yaml |
Three Debian 12 VMs with bridged networking and a 9p host binding |
passt.yaml |
Two Debian 12 VMs with rootless passt networking |
almalinux9.yaml |
Multi-role AlmaLinux 9 cluster (web, db, bastion) with bridged networking |
almalinux10.yaml |
Same layout using AlmaLinux 10 images |
talos.yaml |
Three-node Talos/Kubernetes cluster booted from ISO |
graphical-iso-install.yaml |
Graphical ISO installs (NixOS, OpenBSD) via passt |
complete.yaml |
Complete example that showcase all the features of this tool |