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, 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 [flags] [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-move <old-name> <new-name> |
Rename a project (must be stopped) |
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 (fires pre/post-ssh triggers) |
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] [--latest] [--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 |
host timezone (UTC fallback) |
VIRTDEV_LOCALE |
host locale (en_US.UTF-8 fallback) |
VIRTDEV_KEYMAP |
host keymap (us fallback) |
VIRTDEV_DNS |
9.9.9.9 |
VIRTDEV_PACKAGES |
(none) |
VIRTDEV_SCRIPT |
(none) |
VIRTDEV_INVENTORY |
(none) |
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.
Triggers are user-supplied scripts that run at defined points in a
command's lifecycle. Currently supported events: pre-ssh and post-ssh.
~/.config/virtdev/triggers/pre-ssh # system (all projects)
~/.config/virtdev/projects/myproject/triggers/pre-ssh # per-project
System triggers fire first, then per-project. Each must be an executable
file. Triggers inherit the calling process's environment. virtdev exports
VIRTDEV_PROJECT, VIRTDEV_PORT, and VIRTDEV_SSH_KEY before firing
pre-ssh triggers; post-ssh triggers also receive VIRTDEV_SSH_EXIT.
For pre-ssh, stdout is treated as SSH config lines and incorporated into the SSH config assembly (see below). A non-zero exit aborts the connection (exit code 80). For post-ssh, stdout is ignored and a non-zero exit produces a warning.
virtdev-ssh assembles its SSH configuration from four sources (highest
priority first):
- Per-project pre-ssh trigger output
- System pre-ssh trigger output
~/.config/virtdev/projects/<name>/ssh_config~/.config/virtdev/ssh_config
The assembled config is written to a temporary file and passed via
ssh -F. The user's ~/.ssh/config is intentionally excluded — virtdev
connects to untrusted virtual machines and dangerous global settings
(ForwardAgent, ControlMaster) should not leak in.
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/
ssh_config system-level SSH config for all projects
triggers/
pre-ssh, post-ssh system-level trigger scripts
maintenance/
provision auto-run by virtdev-maintain (dotfiles, tools)
inventory before/after diff by virtdev-maintain
projects/<name>/
ssh_config per-project SSH config (overrides system)
triggers/
pre-ssh, post-ssh per-project trigger scripts (override system)
manifest canonical backup manifest (survives nuke)
provision auto-run by virtdev-recreate
GNU Affero General Public License v3.0 — see LICENSE.AGPLv3.