Per-project KVM/QEMU virtual machines for isolated development.
Each project gets its own Arch Linux VM backed by a thin qcow2 delta over a shared sealed base. The isolation boundary is a hardware-assisted hypervisor, not a namespace or permission system.
- Arch Linux host, bash >= 5.2
- KVM-capable CPU, QEMU (
qemu-system-x86), OVMF (edk2-ovmf) - OpenSSH (
openssh), socat, rsync, jq, archiso
From the AUR (virtdev-git):
yay -S virtdev-git
From source (no install step needed — scripts auto-detect the layout):
git clone https://github.com/matheusmoreira/virtdev.git
cd virtdev
Build the base system that all project VMs derive from:
virtdev key # generate SSH key pair
virtdev iso # build Arch Linux installer ISO
virtdev install # install base system to qcow2 disks
virtdev seal # mark base read-onlyvirtdev create myproject # derive a thin delta VM
virtdev start myproject # boot it (systemd user service)
virtdev wait myproject # wait for SSH
virtdev ssh myproject # connectvirtdev ssh myproject # develop
virtdev stop myproject # shut down (ACPI, SIGTERM fallback)
virtdev start myproject # boot again laterProject VMs are expendable. Automate setup with a provision script:
# ~/.config/virtdev/projects/myproject/provision
sudo pacman -S --noconfirm --needed neovim ripgrep fd
git clone https://github.com/me/dotfiles ~/dotfiles
make -C ~/dotfiles installRun it manually on a fresh VM:
virtdev ssh myproject bash -s < ~/.config/virtdev/projects/myproject/provisionOr let virtdev-recreate run it automatically (see below).
Preserve state that provisioning cannot reproduce (project memories, untracked files, dotfiles, shell history).
Write a backup manifest at ~/.config/virtdev/projects/myproject/manifest:
.claude/
project-a/notes.md
project-a/.env.local
.bashrc
.config/nvim/
Paths are relative to /home/dev/ in the guest. Then:
virtdev backup myproject # snapshot listed paths to host
virtdev backup --list myproject # list existing snapshots
virtdev restore myproject # restore latest snapshot
virtdev restore myproject 2026-04-25/14-30-22 # restore a specific oneBackups survive virtdev-destroy but are removed by virtdev-nuke.
A project-local manifest at ${VIRTDEV_HOME}/projects/myproject/manifest
takes precedence when present (for one-off experiments; discarded with the VM).
Rebuild a project VM on the current sealed base without losing state:
virtdev recreate myprojectThis chains: backup, stop, destroy, create, start, wait, provision, restore. It prompts once (type the project name), then drives each step. On failure, it prints the command to resume from the failed step.
If there is a provision script at
~/.config/virtdev/projects/myproject/provision, recreate discovers and
runs it automatically between start and restore.
Flags: --no-backup, --no-restore, --no-provision, --provision <path>,
--yes/-y, --verbose/-v.
Update the sealed base (system packages, dotfiles, etc.):
virtdev maintain # copies base to staging, boots writable VM
virtdev ssh maintenance # connect from another terminal
# ... perform maintenance inside the VM ...
sudo poweroff # triggers reseal promptOptional hooks in ~/.config/virtdev/maintenance/:
provision— runs inside the guest after SSH is up (dotfiles, tools)inventory— captures system state before and after; diff shown before reseal
Flags: --yes/-y, --no-provision, --no-inventory.
After resealing, existing project VMs refuse to boot (generation mismatch). Recreate them:
virtdev recreate myprojectOr use virtdev upgrade to do everything in one command — back up all
projects, maintain the base, and rebuild them all on the new base:
virtdev upgradeFlags: --only=a,b, --except=c,d, --skip-outdated, --yes/-y,
--verbose/-v.
A project can be detached from the sealed base, converting its delta images
into standalone images. Detached projects boot without a generation check, are
skipped by virtdev upgrade, and must be updated independently:
virtdev stop myproject
virtdev detach myproject
virtdev start myprojectUse --in-place to modify images directly instead of convert-then-swap
(less disk usage, no rollback on interruption). Recreating a detached
project reattaches it to the current base.
All commands are available as virtdev <command> (dispatcher) or
virtdev-<command> (direct). virtdev help <command> shows usage.
| Command | Description |
|---|---|
virtdev-key |
Generate ed25519 SSH key pair |
virtdev-iso |
Build the Arch Linux installation ISO |
virtdev-install [iso] |
Install base system to qcow2 disks |
virtdev-seal |
Seal installation as read-only base |
virtdev-maintain [flags] |
Boot sealed base for maintenance, reseal on exit |
| Command | Description |
|---|---|
virtdev-create <project> |
Derive a project VM from the sealed base |
virtdev-start <project> [port] |
Start VM as a systemd user service |
virtdev-stop <project> |
ACPI shutdown with SIGTERM fallback |
virtdev-destroy [-y] <project> |
Delete a project VM (confirmation required) |
virtdev-detach [--in-place] [-y] <project> |
Convert delta images to standalone, removing base dependency |
virtdev-recreate [flags] <project> |
Backup, destroy, rebuild, provision, restore |
virtdev-upgrade [flags] |
Back up, maintain base, rebuild all projects |
virtdev-nuke |
Delete all virtdev data (confirmation required) |
| Command | Description |
|---|---|
virtdev-ssh <project> [args...] |
SSH into a running virtual machine |
virtdev-console <project> |
Serial console (detach: Ctrl-]) |
virtdev-wait <project> |
Poll until SSH is available |
virtdev-transfer <project> <src> <dest> |
rsync files (prefix remote path with :) |
virtdev-list |
List projects with port, status, and generation (colored) |
| Command | Description |
|---|---|
virtdev-status <project> |
Print running or stopped |
virtdev-port <project> |
Print SSH port of a running virtual machine |
virtdev-pid <project> |
Print QEMU process ID |
virtdev-path <project> [resource] |
Print path to project resource |
virtdev-disk <project> |
Show disk usage info |
virtdev-log [-f] <project> |
Show journal logs (shorthand for journalctl) |
virtdev-monitor <project> |
Attach to QEMU monitor |
virtdev-generation [project] |
Print base or project generation |
virtdev-stale |
List projects with stale base generation |
| Command | Description |
|---|---|
virtdev-backup [--list] [--verbose] <project> |
Snapshot guest paths to host |
virtdev-restore [--verbose] <project> [snapshot] |
Restore a snapshot into a running VM |
Environment variables (defaults shown):
| Variable | Default |
|---|---|
VIRTDEV_HOME |
~/.local/share/virtdev |
VIRTDEV_SSH_KEY |
${VIRTDEV_HOME}/ssh/id |
VIRTDEV_CACHE |
~/.cache/virtdev |
VIRTDEV_TIMEZONE |
UTC |
VIRTDEV_ISO_PROFILE |
auto-detected |
VIRTDEV_ISO |
${VIRTDEV_CACHE}/virtdev.iso |
VIRTDEV_SYSTEM_DISK_SIZE |
24G |
VIRTDEV_HOME_DISK_SIZE |
48G |
VIRTDEV_VM_MEMORY |
4096 (MB) |
VIRTDEV_VM_CPUS |
4 |
VIRTDEV_STOP_TIMEOUT |
60 (seconds) |
VIRTDEV_WAIT_TIMEOUT |
120 (seconds) |
OVMF_CODE |
/usr/share/edk2/x64/OVMF_CODE.4m.fd |
OVMF_VARS |
/usr/share/edk2/x64/OVMF_VARS.4m.fd |
VIRTDEV_HOME and VIRTDEV_CACHE follow XDG defaults
(${XDG_DATA_HOME} and ${XDG_CACHE_HOME} respectively).
All commands support --color=yes|no|auto (default: auto). Auto enables
color when stderr is a terminal, NO_COLOR is unset, and TERM is not
dumb. Colors come from terminfo via tput, not hardcoded ANSI escapes.
Output convention: user-facing messages go to stderr, machine-readable output (ports, paths, PIDs, status words) goes to stdout.
See DESIGN.md for the full architecture, threat model, locking model,
SSH hardening, and known limitations.
system/ sealed base (mode 444)
system.qcow2 OS, bootloader, packages
home.qcow2 /home/dev template
nvram UEFI variable store
generation monotonic counter, bumped on reseal
projects/<name>/ per-project (writable deltas)
system.qcow2 --backs--> system/system.qcow2
home.qcow2 --backs--> system/home.qcow2
nvram copy of system/nvram
generation must match system/generation to boot
Project VMs are thin deltas. Only divergent writes consume disk space.
- vda (system) — OS, bootloader, installed packages
- vdb (home) —
/home/devand all project work
The system disk can be updated or replaced without touching project state.
VMs run as transient systemd user services (virtdev-<project>.service):
systemctl --user status virtdev-myproject
journalctl --user -u virtdev-myprojectEach virtual machine's hostname is set to the project name at boot
(via QEMU fw_cfg), so the guest prompt shows dev@myproject.
Mutating commands take an exclusive flock(2) on ${VIRTDEV_HOME}/lock
and fail fast on contention (exit 75). cat ${VIRTDEV_HOME}/lock shows
the holder's PID.
${VIRTDEV_HOME}/ (~/.local/share/virtdev)
lock flock(2) target; holder PID
ssh/id, ssh/id.pub SSH key pair
system/ sealed base (mode 444)
maintenance/ transient staging for virtdev-maintain
projects/<name>/
system.qcow2, home.qcow2 delta disks
nvram, generation UEFI state, base generation
port, monitor.sock, console.sock runtime (while running)
manifest optional project-local manifest
backups/<project>/<date>/<time>/
project, manifest, generation metadata
tree/ user content
${VIRTDEV_CACHE}/ (~/.cache/virtdev)
virtdev.iso built ISO
work/, profile/ mkarchiso artifacts
~/.config/virtdev/
maintenance/
provision auto-run by virtdev-maintain (dotfiles, tools)
inventory before/after diff by virtdev-maintain
projects/<name>/
manifest canonical backup manifest (survives nuke)
provision auto-run by virtdev-recreate
GNU Affero General Public License v3.0 — see LICENSE.AGPLv3.