No description
  • Go 98.8%
  • Nix 1%
  • Makefile 0.2%
Find a file
2026-05-19 19:21:43 +02:00
cmd chore(svm): add todo 2026-05-18 23:57:56 +02:00
examples feat(svm): add prot-forwarding using passt 2026-05-18 18:50:10 +02:00
pkg refactor(svm): rename some file 2026-05-19 19:21:43 +02:00
.gitignore chore(svm): add gitignore 2026-05-17 11:55:37 +02:00
flake.lock feat(svm): add project 2026-05-17 11:55:28 +02:00
flake.nix fix(svm): flake build 2026-05-17 11:55:37 +02:00
go.mod feat(svm): add templating of config using sprig and user-defined vars 2026-05-18 16:18:03 +02:00
go.sum feat(svm): add templating of config using sprig and user-defined vars 2026-05-18 16:18:03 +02:00
main.go feat(svm): move all commands into cmd directory 2026-05-18 14:26:50 +02:00
Makefile chore(make): compile on install 2026-05-18 15:22:15 +02:00
README.md docs(svm): remove some errors in the readme 2026-05-18 18:25:12 +02:00

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