voom: run magic-free local VMs.
Voom is a CLI for running and managing Linux-based virtual machines. Voom supports MacOS hosts (via vfkit) and Linux hosts (via QEMU/KVM).
Voom is deliberately simple and not magical. In particular, Voom has:
- no daemon
- no user-edited config files
- all runtime state lives under
$XDG_*paths - explicit VM lifecycle:
import → create → start → stop
Optional features include:
- automatic host-to-guest port forwarding (with configurable per-VM offsets to avoid collisions) and host directory mounts
- keeping secrets out of the guest using per-VM explicit HTTP proxy attachments, enabling external brokers such as Agent Vault (source), iron-proxy (source), etc. to dynamically supply credentials at request time
- USB passthrough for QEMU VMs on Linux, including live attach and detach
Note that there are many excellent tools in this space, with differing goals and trade-offs. Kevin Lynagh's Vibe is one such example for Mac users; see its list of alternatives for a primer on available options.
Voom might be a nice choice for you if you have the need for disposable-ish,
pseudo-ephemeral VMs (great for letting your agents --yolo, among plenty of
other uses).
Voom works with off-the-shelf cloud images, as well as custom images that you build yourself (see Custom Images).
The following examples use official Debian cloud
images, in conjunction with the
--install-guest-helpers flag (provided as an argument to voom image import). The --install-guest-helpers flag installs Voom's guest helpers when
the VM boots for the first time, which enable host directory shares and
automatic port forwarding. (If you don't want host directory shares or
automatic port forwarding, you can omit the flag when importing an image.)
Choosing a Debian image variant
Use the generic variant. The similarly-named nocloud variant ships
without cloud-init and boots straight to a root prompt, ignoring Voom's seed
(so --install-guest-helpers and SSH-key injection will silently have no
effect). The genericcloud variant should work but ships a reduced kernel
driver set; prefer generic unless image size matters.
Terminology note: Voom's cloud-init seed uses a NoCloud datasource,
which is unrelated to Debian's nocloud image variant.
Install Voom, and all required host runtime dependencies.
On an Apple Silicon Mac (aarch64), use a raw disk image:
BUILD=20260518-2482
URL=https://cloud.debian.org/images/cloud/trixie/$BUILD
IMG=debian-13-generic-arm64-$BUILD.raw
curl -fLO "$URL/$IMG"
curl -fLO "$URL/SHA512SUMS"
shasum -a 512 --ignore-missing -c SHA512SUMS
voom image import debian13 ./"$IMG" --ssh-user debian --arch aarch64-linux --install-guest-helpersOn a Linux host (this example is x86_64), use a qcow2 disk image:
BUILD=20260518-2482
URL=https://cloud.debian.org/images/cloud/trixie/$BUILD
IMG=debian-13-generic-amd64-$BUILD.qcow2
curl -fLO "$URL/$IMG"
curl -fLO "$URL/SHA512SUMS"
sha512sum --ignore-missing -c SHA512SUMS
voom image import debian13 ./"$IMG" --ssh-user debian --arch x86_64-linux --install-guest-helpersaarch64 Linux hosts are also supported.
Tip
Always use raw disk images on Mac hosts, and qcow2 disk images on Linux
hosts.
Create a new VM (called deb, in this example), based on the debian13 image
that was imported in the previous step:
voom create deb --image debian13New VMs are allocated 4 vCPUs and 4 GiB (4096 MiB) of RAM by default. You can
override these values at VM creation time by passing the --cpus / --memory
flags.
Declare a host directory as a virtio-fs share:
voom share add deb code ~/src/myproject /mnt/codeShares can only be added or removed while the VM is stopped; this host
directory (~/src/myproject) will mount automatically on boot (at /mnt/code
on the guest).
voom start deb
# Wait for first-boot cloud-init to install the guest helpers
until voom ssh deb -- 'systemctl is-active voom-portfwd.service' 2>/dev/null \
| grep -q '^active$'; do sleep 5; done
voom ssh deb -- ls /mnt/code # one-shot command
voom ssh deb # interactive shellWhen auto-forwarding is enabled, anything the guest binds on 0.0.0.0 is
available at 127.0.0.1 on the host by default:
voom forward auto enable deb
voom ssh deb -- 'nohup python3 -m http.server 8080 >/dev/null 2>&1 &'
sleep 3
voom forward list deb
curl http://127.0.0.1:8080If you're running multiple VMs that each bind the same guest ports, you can
assign each VM an offset to prevent host collisions. For example, voom forward auto enable deb --offset 10000 maps host 18080 to guest 8080 (host port = guest port + offset). To adjust an existing offset, run voom forward auto offset deb <n>.
To expose auto-forwards beyond loopback, pass --bind <ip> or --lan when
enabling auto-forwarding. For example, --bind 100.x.y.z listens on that local
host address, while --lan listens on 0.0.0.0.
If you would prefer to explicitly expose ports:
voom ssh deb -- 'nohup python3 -m http.server 9090 >/dev/null 2>&1 &'
voom forward add deb 9090 # host 127.0.0.1:9090 -> guest 9090
sleep 3
curl http://127.0.0.1:9090The host port defaults to the guest port and binds to 127.0.0.1; pass
--host-port to map to a different host port, or --lan to expose on
0.0.0.0.
Run voom stop to shut down a running VM:
voom stop debThis command clears active runtime state (sockets and process records), but keeps persistent state and logs.
Certain changes can only be made while the VM is stopped:
voom resources cpus deb 8 # set the vCPU count
voom resources memory deb 8192MiB # set the RAM allocation
voom resources disk grow deb 20G # grow the disk by 20GNote that the disk will be resized immediately, but updated CPU and memory allocations will not take effect until the next time the VM is started.
Note
Release and Nix installations include Voom's gvproxy build. Other host runtime dependencies, such as QEMU, vfkit, and SSH, are not included. See Host Requirements.
Download a prebuilt archive for your platform from the GitHub releases
page, verify it against
checksums.txt, extract the voom binary, and place it somewhere on your
PATH. Keep the extracted gvproxy binary in the same directory as voom.
Important
The release binaries are not notarized by Apple, so MacOS Gatekeeper will quarantine the downloaded binaries. You'll see a message like "Apple could not verify ... is free of malware". Clear the quarantine attribute before running it:
xattr -d com.apple.quarantine ./voom ./gvproxynix profile install github:mjrusso/voomOr run directly without installing:
nix run github:mjrusso/voom -- versionTo use voom from another flake:
{
inputs.voom.url = "github:mjrusso/voom";
outputs = { nixpkgs, voom, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in {
devShells.${system}.default = pkgs.mkShell {
packages = [ voom.packages.${system}.default ];
};
};
}In NixOS or Home Manager configs, add voom.packages.${pkgs.system}.default to
environment.systemPackages or home.packages after passing the flake input
to the module.
For instructions on building from source, see CONTRIBUTING.md.
- Linux:
/dev/kvmaccess through KVM, QEMU (qemu-system-<arch>,qemu-img), and OpenSSH (ssh). - MacOS: vfkit and
OpenSSH (
ssh). Note that only Apple Silicon is supported.
Optional integrations: virtiofsd
(Linux/QEMU shares), Nix and
nixos-rebuild
(for voom nixos switch), and Git (NixOS switch
metadata). Note that USB passthrough from Linux hosts requires a QEMU build
with XHCI and libusb usb-host support.
Voom bundles its gvproxy build in release and Nix installations.
Install host runtime tools manually, or with your OS package manager.
On aarch64 QEMU hosts, Voom looks for UEFI firmware in this order:
/usr/share/AAVMF/AAVMF_CODE.fd
/usr/share/qemu/edk2-aarch64-code.fd
/usr/share/edk2/aarch64/QEMU_EFI.fd
Set VOOM_QEMU_AARCH64_UEFI=/path/to/firmware.fd when your distribution stores
firmware elsewhere. On MacOS, vfkit uses direct boot metadata from the imported
image or a bootable disk image.
| Host OS | Host arch | Image system | Driver | Disk format |
|---|---|---|---|---|
| Linux | x86_64 | x86_64-linux |
qemu | qcow2 |
| Linux | aarch64 | aarch64-linux |
qemu | qcow2 |
| Darwin | arm64 / aarch64 | aarch64-linux |
vfkit | raw |
| Area | Commands |
|---|---|
| Lifecycle | create, start, stop, restart, remove, clone, rename |
| Images | image import, image inspect, image list, image remove |
| Compute | resources cpus, resources memory |
| Disk | resources disk grow, disk reset |
| Access | ssh, console, ssh-config, config show, config ssh-port |
| Forwards | forward add, forward remove, forward list, forward discover, forward auto enable/disable/offset |
| Egress | config egress set, config egress clear, config egress enable, config egress disable |
| Shares | share add, share remove, share list |
| USB | usb discover, usb add, usb remove, usb list |
| NixOS | nixos switch |
| Inspect | list, info, logs, events, doctor, guest ports, debug paths, version, skill |
Every list command accepts ls as an alias. Every remove command accepts
rm; voom remove also accepts destroy.
Notes and considerations:
-
Host SSH ports are auto-allocated (in the range 2222–2299); the selection is persisted as part of the VM metadata. You can set an arbitrary port at VM creation time with the
--ssh-portflag, and existing port allocations can be changed later by running thevoom config ssh-portcommand. -
A VM must be stopped before resizing, changing shares, growing its disk (
voom resources disk grow), or changing its SSH port. -
Most non-streaming commands accept
--output jsonto simplify automation; streaming commands (ssh,console,nixos switch) are instead text/subprocess oriented.
For the full command reference, see docs/commands.
An explicit HTTP proxy attachment connects one VM to an external proxy or broker. The broker can apply VM-specific policy and supply credentials without storing them in the guest. Each attachment records a private backend Unix socket on the host:
Guest application
├── uses the explicit proxy
│ └── 192.168.127.1:3128
│ └── Voom-managed listener on the guest gateway
│ └── TCP-to-Unix route
│ └── backend Unix socket assigned to this VM
│ └── external proxy or adapter
│ └── destination
│
└── connects directly
└── ordinary direct networking
└── destination
Voom manages the fixed listener on the guest gateway and the TCP-to-Unix route.
The external proxy or its adapter creates and listens on the backend Unix
socket. Voom also publishes /run/voom/egress.json and an optional CA bundle at
/run/voom/egress-ca.pem. Guest tooling can read the manifest to configure
applications. Voom does not run or manage the external proxy.
Guest applications must explicitly use the published proxy address. Voom does
not set HTTP_PROXY, HTTPS_PROXY, or application proxy settings. It also does
not block direct network access. Voom publishes the optional CA bundle but does
not install it. HTTP and HTTPS clients use the same proxy address. HTTPS clients
use HTTP CONNECT through that address.
Attachments require an image with controlShare capability. Check the imported
image before you configure the attachment:
voom image inspect <name>The output must show capabilities: controlShare=true. Images imported with
--install-guest-helpers have this capability. For custom images, see the
Guest Image Contract. Before you set or enable an
attachment, start the external proxy so its backend socket accepts connections.
voom config egress set agent-a \
--backend-socket /run/credential-proxy/vm-01JXYZ.sock \
--ca-cert /etc/credential-proxy/ca.pem
voom start agent-aInside the guest, an application can use the fixed proxy address directly:
curl --proxy http://192.168.127.1:3128 \
--cacert /run/voom/egress-ca.pem \
https://example.com/The --ca-cert option publishes a CA bundle for proxies that terminate TLS.
Omit --ca-cert and --cacert when the proxy does not terminate TLS. Use
voom config egress disable and voom config egress enable to change proxy
access while the VM runs. To remove the saved attachment, stop the VM and use
voom config egress clear.
For attachment states, external-manager integration, guest files, security requirements, and recovery behavior, see Egress proxy integration.
Example integrations:
- Agent Vault on NixOS provides a complete Voom integration with per-VM identity, host-only proxy tokens, CA setup, and guest wrappers.
- iron-proxy's CONNECT tunnel guide and example configuration provide a starting point for explicit proxying, credential injection, and CA setup. Bridge its TCP tunnel listener to a private Unix socket before attaching it to Voom.
Voom ships agent instructions as part of the binary. To print the skill file,
run voom skill.
Installation is not required (just tell your agent to run voom skill). You
can optionally install the skill; for example:
mkdir -p ~/.agents/skills/voom
voom --skill > ~/.agents/skills/voom/SKILL.md
chmod 0644 ~/.agents/skills/voom/SKILL.mdUse voom doctor to check system dependencies (as per Host
Requirements), as well as writable directories, port
availability, LAN exposure, stale process records, and state consistency.
voom logs <name> reads the serial log by default; pass --kind to inspect
helper logs (qemu, vfkit, gvproxy, auto-forward, share-mount).
For manual recovery, prefer command-level cleanup:
voom stop <name>
voom doctor
voom remove <name> --forceIf a VM is already stopped and only runtime debris remains, it is safe to
remove that VM's <runtime>/vms/<vm-id> directory. Removing files under
<state> is destructive and should be handled with care.
Lifecycle cleanup applies to all VMs, including those without egress attachments. Voom records each helper's PID and system-specific start identity at launch. Cleanup sends signals only when the current values match that launch record. Start cleans up surviving helpers before launching replacement runtime. Stop, remove, and disk reset retain process records and return an error when identity or termination cannot be verified. Inspect the reported PID and logs before removing retained recovery records.
Voom's guest integrations can be configured to be installed on first boot by
passing the --install-guest-helpers flag on image import. Alternatively, they
can be baked into a custom-built image and paired with a sidecar (a small
JSON file that declares the image's capabilities).
--install-guest-helpers is simpler than baking a custom image, but there are
some trade-offs and differences worth acknowledging:
--install-guest-helpers |
Bake into the image (+ sidecar) | |
|---|---|---|
| Works with | any cloud-init-capable image (e.g. stock Debian generic) |
images you build yourself |
| First-boot cost | one-time install on first boot | none: helpers are already present in the image |
| Network at first boot | required (installs jq/gawk/iproute2 from distro repo) |
not required |
| Enables | shares + auto port-forward | shares + auto port-forward + voom nixos switch |
| Setup | one flag at image import | a build pipeline |
When passing --install-guest-helpers, Voom generates a cloud-init NoCloud
seed that writes Voom's helpers (voom-portfwd, the share-mount service, the
/run/voom control mount) and enables them on first boot. Note that the
install runs once, with subsequent starts reusing the helpers already on disk.
Alternatively, baking the helpers into the image avoids first-boot
latency and the package-install network dependency.
Tip
The author uses Voom with a custom image built from his NixOS system configuration: the same Nix Flake that defines the host machine also produces VM images with tools pre-installed and configured, so it's possible to SSH in, launch tmux, Emacs, and coding agents, and get to work immediately. Because the image ships with nix-direnv, and his projects generally use Nix Flakes to define per-project toolchains, per-project environments load automatically with no additional setup.
If you would like to replicate a similar setup, see this reference
implementation:
module
(wires up the control-share mount, the voom-portfwd and voom-mount-shares
helpers, and their systemd units), and build
script
(bakes the disk image with matching Voom sidecar).
See Guest Image Contract for details on what a full-featured image must provide, and Images for the sidecar format specification.
Voom manages local development VMs through the host's VM stack. Voom does not build images, bundle hypervisors, or run a background control plane. Release and Nix installations include Voom's gvproxy build.
Images must be explicitly imported, VMs must be created explicitly (referencing an existing, already-imported image), and optional guest integrations are always opt-in.
| Purpose | Default (XDG) | Override |
|---|---|---|
| Configuration | $XDG_CONFIG_HOME/voom or ~/.config/voom |
VOOM_CONFIG_DIR |
| State (source of truth) | $XDG_DATA_HOME/voom or ~/.local/share/voom |
VOOM_STATE_DIR |
| Cache (logs) | $XDG_CACHE_HOME/voom or ~/.cache/voom |
VOOM_CACHE_DIR |
| Runtime (sockets, process records) | $XDG_RUNTIME_DIR/voom or /tmp/voom-$UID |
VOOM_RUNTIME_DIR |
Run voom debug paths to inspect resolved values. Voom uses a gvproxy binary
next to its own executable before searching PATH. External helpers can be
overridden with VOOM_GVPROXY, VOOM_VIRTIOFSD, and
VOOM_QEMU_AARCH64_UEFI.
Setting all four VOOM_* overrides to disposable directories is the supported
way to experiment without touching your normal state. This is useful for
testing, scratch work, or locally developing Voom (as per details in
CONTRIBUTING.md).
Names and IDs. VM and image names are mutable labels (matching
^[a-zA-Z0-9][a-zA-Z0-9_-]*$), decoupled from the stable generated IDs that
Voom uses for internal bookkeeping.
Disks. Each VM disk is an independent copy of the image disk. voom image remove blocks while any VM references the image, unless --force is used; a
forced removal leaves existing VM disks in place with their historical
metadata.
SSH login user. Required at import: set it through the sidecar user field
or the --ssh-user flag (the flag wins if both are present). On start, Voom
writes a NoCloud seed authorizing the image's sshIdentityPath public key,
or, when unset, the readable defaults ~/.ssh/id_ed25519.pub, id_ecdsa.pub,
id_rsa.pub, and id_dsa.pub. The seed grants passwordless sudo to root, and
to the login user when the two differ.
Sidecar metadata. A sidecar records an image's properties (system,
format, login user, and so on) and declares which guest integrations are
available:
controlShare— guest can mount the reservedvoom-controlshare at/run/voomguestPortReport— guest runsvoom-portfwdand writes/run/voom/ports.jsonguestShareMount— guest can mount declared host sharesnixosSwitch— guest supportsvoom nixos switch
A typical sidecar:
{
"user": "root",
"nixosTargetUser": "root",
"system": "x86_64-linux",
"format": "raw",
"flake_rev": "abcdef123456",
"baked_at": "2026-05-21T00:00:00Z",
"capabilities": {
"controlShare": true,
"guestPortReport": true,
"guestShareMount": true,
"nixosSwitch": true
}
}Note:
-
nixosTargetUser(ornixos_target_user) overrides the uservoom nixos switchtargets, defaulting touser. -
Setting
"installGuestHelpers": trueis equivalent to passing--install-guest-helperson import.
NixOS images. The recommended pattern is to have your flake build image
outputs for the systems and formats Voom can run, then write a sibling sidecar
declaring at least user, system, format, baked_at, flake_rev, and the
capabilities the image actually ships. The author's NixOS config does this with
outputs named .#images.<system>.<format>; see
scripts/bake-golden
for a reference implementation.
There are two tiers of compatible images:
Minimum tier supports start, stop, restart, ssh, console, manual
forward, logs, info, list. The image must:
- boot on the recorded host architecture;
- use the disk format the driver requires (
qcow2for QEMU,rawfor vfkit); - run sshd on TCP 22 and accept the configured SSH identity;
- network through gvproxy DHCP/user-mode (no bridged or root-privileged setups).
Full-feature tier additionally supports shares, auto-forwarding, and voom nixos switch (when applicable). It must also provide ss (or equivalent),
mount / mountpoint / umount, virtiofs, the voom-portfwd helper, a
service that mounts shares from /run/voom/mounts.json, passwordless privilege
escalation when the NixOS target user is not root, and a conventional
ACPI/vfkit shutdown path.
For NAT-backed Voom images, leave the guest firewall disabled. The guest sits behind gvproxy NAT, so no traffic can reach the guest except through explicit host-side forwards, which are managed by Voom. Use a stricter guest firewall only when attaching to a real network (bridged, macvtap, VPN).
The helper writes /run/voom/ports.json atomically (tmp-and-rename) every
interval. The host treats a report older than 30 seconds as stale. A missing,
stale, or malformed report leaves installed runtime auto-forwards in place. A
valid report is authoritative: Voom removes auto-forwards for listeners that
the report does not list, and a valid empty report removes all of them. Manual
forwards are never affected.
{
"schemaVersion": 1,
"generatedAt": "2026-05-20T12:00:00Z",
"listeners": [
{
"proto": "tcp",
"addr": "0.0.0.0",
"port": 8080,
"pid": 1234,
"process": "python3"
}
]
}Field rules: schemaVersion must be integer 1; generatedAt must be an
RFC 3339 timestamp in UTC; proto must be "tcp" for v1; pid and process
are optional.
Recommended systemd shape:
voom-portfwd.service
after: network-online.target
wants: network-online.target
exec: voom-portfwd --output /run/voom/ports.json --interval 2s
For reference, see the author's NixOS config at
hosts/container/default.nix
(helper script, systemd unit, voom-mount-shares service, /run/voom virtiofs
control-share mount).
Bind addresses must be IP literals; hostnames including localhost are
rejected. Voom treats overlapping binds as conflicts:
0.0.0.0:pconflicts with every IPv4 bind on portp.- A specific IPv4 conflicts with
0.0.0.0and with itself on the same port. ::conflicts with every IPv6 bind on that port and conservatively also with IPv4 binds on the same port unless the host proves it can hold both.::1and other specific IPv6 addresses conflict with::and with themselves on the same port.
A persisted SSH or forward port that is occupied at start time fails rather than silently reallocating.
Auto-forwarding is runtime state, not a manual forward declaration. The guest
helper writes /run/voom/ports.json through the reserved control share; Voom
plans host forwards from fresh reports and records installed/skipped rows in
<runtime>/vms/<vm-id>/auto-forwards.json. Rules:
- only TCP listeners are considered;
- guest port
22is reserved for SSH; - guest listeners must bind all guest interfaces (
0.0.0.0,::, or*); - runtime auto-forwards bind
127.0.0.1on the host by default; pass--bind <ip>or--lanto expose them elsewhere; - host port = guest port + configured auto-forward offset.
- changing the bind or offset removes runtime auto-forwards that no longer match immediately, without waiting for a guest report; forwards for the new bind or offset appear after the next valid report.
voom forward discover <name> is an audit/preview command; voom forward list
shows effective rows including skipped auto-forwards with an explanation.
This table shows the expected healthy state. voom info reports the observed
runtime state and any difference from the saved attachment.
| Saved attachment | VM state | Private listener | Published manifest |
|---|---|---|---|
| None or disabled | Stopped or running | No | No |
| Enabled | Stopped | No | No |
| Enabled | Running | Yes | Yes |
Set and clear require a stopped VM with no surviving runtime. Enable and disable also work while the VM runs. Run enable again to repair runtime drift. If the runtime already matches the saved attachment, enable preserves the listeners, tunnels, and runtime files. Disable closes the private listener, pending dials, and active tunnels. Disable cannot retract requests that a broker already accepted or guarantee cancellation of upstream work.
External attachment managers should pass the immutable VM ID with every change. They should first save the attachment in the disabled state:
voom info agent-a
VM_ID=30BD3BFA3D7C382195F82F603B5D4F11
voom config egress set agent-a \
--expect-id "$VM_ID" \
--disabled \
--backend-socket "/run/credential-proxy/$VM_ID.sock" \
--ca-cert /etc/voom-proxy/ca.pem
voom config egress enable agent-a --expect-id "$VM_ID"--expect-id is available on set, clear, enable, and disable. Voom checks it
while it holds the VM state lock. Thus, a replacement VM with the same name
cannot receive the old attachment. set --disabled validates and reserves the
socket without publishing a guest route. Voom also validates the CA bundle. The
manager can then complete its policy checks and confirm that no emergency hold
applies before it enables the attachment.
Assign each backend socket and broker policy to the immutable VM ID shown by
voom info, not the mutable VM name. Voom reserves a socket across stopped and
disabled VMs in its state store. Operators must enforce uniqueness across other
state stores, users, and clients. Rename preserves attachments. Clones omit them
and report the new VM ID for a separate backend.
The external proxy or broker, not Voom, controls authentication, credential injection, and policy enforcement. Proxy use is advisory, and direct networking remains available. Requests that bypass the proxy receive no broker-injected credentials.
Keep socket directories and broker policy under trusted host control. A shared unauthenticated listener for all sockets does not preserve VM identity. Guest headers and manifest contents do not authenticate a VM.
Voom publishes these files through voom-control when it enables an attachment:
/run/voom/egress.json: schema version 1, modeexplicit, andhttpProxyandhttpsProxyset tohttp://192.168.127.1:3128./run/voom/egress-ca.pem: optional validated public certificate bundle. The manifest includescaCertificateonly when this file is configured.
Host copies live under <runtime VM directory>/control/. Files have mode 0644.
Voom publishes the manifest after the CA file. Voom replaces the files
separately, so the update is not atomic. The metadata document always advertises
controlShare.egressPath, including when the saved configuration has no
attachment. Guests must choose how to consume the manifest and install the
public CA.
CA bundles must contain only X.509 certificate PEM blocks and at least one CA
certificate. Voom rejects private keys, other PEM blocks, and extraneous data.
A bundle can contain a maximum of 4 MiB. Each operation publishes the exact
bytes that it validated. info and doctor compare the published CA to the
current source. After the source changes, run enable or restart to update the
guest files.
The egress-config column of voom list shows the saved attachment (enabled,
disabled, or none) and does not check the running VM. voom info reports
observed route state, connection count when available, and configuration drift.
voom doctor uses local socket probes and host API queries. voom doctor sends
no proxy requests or outbound internet traffic. Disabled declarations can start
without their backend, certificate, or private-transport capability.
Disable persists the disabled declaration after removing runtime access. A crash or persistence failure between those steps can leave the saved attachment enabled. A later restart can restore access.
A failed enable can leave an enabled declaration with runtime access removed. After you fix the reported cause, run enable again. If Voom cannot confirm route removal, it tries to stop the VM runtime. Unconfirmed termination is an error. Voom retains the process record and log paths for recovery.
voom share add <name> <tag> <host-path> <guest-path> declares a virtio-fs
share in vm.json; append --ro/--readonly for a read-only mount. The tag
voom-control is reserved. On QEMU, Voom exports each share through a
virtiofsd helper; on vfkit, it attaches native virtio-fs devices. The guest
mounts declared shares from /run/voom/mounts.json (delivered over the reserved
control share), so the image must ship the full-feature guestShareMount
capability. Adding or removing a share requires the VM to be stopped.
USB passthrough provides a VM with direct access to USB devices connected to the host. Voom supports USB passthrough from Linux hosts, but does not support USB passthrough from MacOS hosts.
Use these commands to discover, assign, list, and remove USB devices:
voom usb discover
voom usb add dev board usb-0000:00:14.0@2-3.2
voom usb list dev
voom usb remove dev boardvoom list reports the configured assignment count as usb=N. voom info dev
reports each assignment's host state (connected, disconnected, inaccessible,
or unknown) and runtime state (active, missing, stopped, or unknown).
The location consists of a stable host-controller identity, a USB protocol domain, and the port path. On Linux, Voom finds the current bus number for the controller whenever it starts or attaches the assignment, then passes that bus and port to QEMU. Voom does not persist the numeric bus. QEMU checks the route periodically, so a disconnected device can attach later and a device can re-enumerate on the same route. QEMU exposes any USB device inserted on that route to the guest while the VM is running. Multiple stopped VMs can use the same assignment, but Voom prevents them from running with that assignment at the same time.
If Voom cannot confirm a live USB removal, it keeps the VM running and retains the assignment so the route remains reserved. Retry the removal to reconcile the saved assignment with QEMU.
USB 2 and USB 3 sides of one physical connector are separate topology routes.
A device that reconnects at a different USB protocol can require a different
assignment. voom usb discover reports the route used by the currently
connected device.
The user running Voom needs read/write access to the corresponding
/dev/bus/usb/<bus>/<address> node. voom usb discover reports whether the
current device is accessible. voom usb add and voom start reject a connected
device if Voom cannot open its device node with read/write access. Configure
access through a host udev rule. On a systemd desktop, for example, this rule
grants the active user access to devices with vendor ID 1234:
SUBSYSTEM=="usb", ATTR{idVendor}=="1234", TAG+="uaccess"
Replace 1234 with the vendor ID reported by voom usb discover. Use the
narrowest suitable vendor or product match for the hardware. Reload the rules
and reconnect the device after changing them. Voom does not install udev rules.
The guest must include the drivers and software required by the device. Guest support can include XHCI, CDC ACM, USB serial, hidraw, or libusb. Guest udev rules or group membership must also grant the intended account access. You can add and remove assignments while the VM is running. Clones do not inherit USB assignments.
State is the persistent source of truth. JSON files are written atomically via temp-file-and-rename; partial files are ignored on load.
<state>/state.json # name → ID index
<state>/images/<image-id>/image.json # metadata, capabilities
<state>/images/<image-id>/disk.qcow2|raw # imported image disk
<state>/vms/<vm-id>/vm.json # config, forwards, shares, USB assignments
<state>/vms/<vm-id>/disk.qcow2|raw # per-VM disk copy
<state>/locks/... # state and per-VM locks
Runtime files are disposable. Notable paths under <runtime>/vms/<vm-id>/:
vm.process.json,gvproxy.process.json,auto-forward.process.json,virtiofs-<tag>.process.json— process IDs and system-specific start identitiesseed.img— regenerated cloud-init NoCloud disk (meta-data,user-data,network-config)control/mounts.json,control/ports.json— host side of the reservedvoom-controlsharenetwork.sock,qemu.mon,vfkit.sock,virtiofs-<tag>.sock— driver and helper sockets
Cache holds disposable logs: serial.log, qemu.log or vfkit.log,
gvproxy.log, auto-forward.log, share-mount.log, and the global
events.jsonl event stream. The stream retains one rotated generation at
events.jsonl.1 and coordinates writers through events.lock. Use voom logs <name> to read per-VM logs.
voom events streams best-effort change notifications from the cache-backed
event log. With no --since value it follows new events. --since accepts an
RFC3339 timestamp, Unix timestamp, duration such as 10m, or an event ID.
--until accepts a timestamp or a duration into the future. JSON output is
JSON Lines and is flushed after every event:
voom events --output json --filter type=forwardEvents are wake-up hints, not a state replica. A consumer must reconcile with
voom list or voom forward list when it starts and whenever it receives an
event. Delivery is not guaranteed, duplicates are possible, and concurrent
writers do not provide causal ordering. An event-ID cursor that has aged out
of the retained log produces a non-zero exit instead of silently replaying an
incomplete history.
Each JSON event has recordType, schemaVersion, opaque id, time,
timeNano, type, action, actor, and diagnostic source fields. Actor
attribute values are strings. Consumers must ignore unknown fields, event
types, and actions. Unsupported schema versions cause the stream to exit.
The initial event taxonomy is:
| Type | Actions | Meaning |
|---|---|---|
vm |
create, start, stop, rm |
An observed VM lifecycle operation completed; cloned VMs use create with a clonedFrom attribute. |
forward |
install, uninstall, skip |
Persisted automatic-forward state changed. |
Events for an unexpected VM process exit are not emitted in this version.
voom doctor scans object directories in addition to state.json and reports
dangling index entries, orphaned records, duplicate names or IDs, LAN exposure,
stale process records, stale auto-forward state, and malformed guest port reports.
Common command effects:
| Command | Reads | Writes or removes |
|---|---|---|
voom image import |
source disk, optional sidecar metadata | <state>/state.json, <state>/images/<image-id>/image.json, imported image disk |
voom image remove |
state.json, image.json, VM references |
image record and disk; state.json entry |
voom create |
state.json, image.json, image disk |
state.json, vm.json, VM disk copy, event log |
voom clone |
state.json, source vm.json, source VM disk |
state.json, new vm.json, VM disk copy (no shares/forwards), event log |
voom start |
state.json, vm.json, image.json, VM disk |
runtime directory, seed.img, control share files, sockets, process records, helper logs, runtime auto-forward state, event log |
voom stop |
state.json, vm.json, runtime process records |
stops runtime helper processes; removes sockets, process records, and the runtime auto-forward state file; writes event log |
voom remove |
state.json, vm.json, runtime process records |
removes VM state, VM disk, runtime directory, cache logs, and state.json entry; writes event log |
voom resources cpus / memory |
state.json, vm.json, runtime process record |
updates stopped-VM CPU or memory allocation in vm.json |
voom resources disk grow |
state.json, vm.json, VM disk |
grows the stopped VM disk |
voom disk reset |
state.json, vm.json, image.json, image disk |
replaces the VM disk and updates the VM image/access metadata |
voom forward add / remove |
state.json, vm.json, runtime socket when running |
updates declared forwards in vm.json; exposes or unexposes gvproxy forwards for running VMs |
voom forward auto enable / disable / offset |
state.json, vm.json, image capabilities, runtime report when running |
updates auto-forward settings in vm.json; for running VMs, removes runtime auto-forwards that no longer match the bind or offset and starts or stops the watcher, which reconciles from guest reports; writes the event log on transitions |
voom share add / remove |
state.json, vm.json, host path |
updates share declarations in vm.json; running VMs must be stopped first |
voom nixos switch |
state.json, vm.json, image capabilities, flake metadata |
runs nixos-rebuild over SSH and records switch metadata in vm.json |
voom config show |
state.json, vm.json |
no state changes |
voom config ssh-port |
state.json, vm.json, runtime process record, host port availability |
updates the stopped VM's SSH management port in vm.json |
voom logs |
state.json, vm.json, cache log |
no state changes |
voom doctor |
state, runtime, cache, host tools, process records | no state changes |
voom events |
<cache>/events.jsonl, retained generation |
no state changes beyond creating the event lock while waiting |
For development setup, source builds, checks, generated docs, package boundaries, and the release process, see CONTRIBUTING.md.
Voom is released under the terms of the MIT License.
Copyright (c) 2026, Michael Russo.
