- Shell 100%
| .gitignore | ||
| build-vm.sh | ||
| README.md | ||
| USER_README.md | ||
agent-vm
Spin up fresh, disposable Arch Linux coding VMs — fast. Each VM is built from scratch with today's packages, but the entire system is assembled on-disk from the host (no slow "boot the VM and run pacman over SSH" phase). The VM's very first boot is already a finished, up-to-date dev environment.
One script, build-vm.sh, does everything.
Why it's fast and fresh
| Goal | How |
|---|---|
| Fresh (packages change daily) | Every build installs current versions from your host's pacman cache/DB — no frozen base image |
| No download | Packages come from the host's local cache (/var/cache/pacman/pkg), which you keep current with pacman -Syu |
| No provisioning boot | The rootfs is built against a loop-mounted raw image with pacman --root / chroot / systemctl --root — the VM is never booted to install anything (converted to qcow2 at the end) |
| No slow keyring init | The host's already-initialized /etc/pacman.d/gnupg is copied in, skipping pacman-key's gpg keygen |
No locale-gen |
Uses the built-in C.UTF-8 locale |
| No bootloader | QEMU boots the kernel directly (-kernel/-initrd), so there's no ESP/GRUB/systemd-boot to install |
| Lean image | linux-firmware is omitted — a virtio VM needs no device firmware (~300 MB saved) |
| Built in RAM | The rootfs is assembled on a tmpfs inside the build's private mount namespace, so pacman/mkinitcpio I/O never touches the SSD; only the final qcow2 is written to disk |
Net result: a complete VM in well under a minute, entirely offline.
Requirements
- An Arch Linux host (the build sources packages, keyring, and sync DB from it).
Keep the package cache current:
sudo pacman -Syu. - root (via
sudo) — the build useslosetup,mkfs,mount, andchroot. - Packages on the host:
qemu(providesqemu-system-x86_64,qemu-img),e2fsprogs(mkfs.ext4),util-linux(losetup,unshare,mount), andpsmisc(fuser, for clean unmounts). - An SSH keypair at
~/.ssh/id_ed25519.pub(optional — copied in for key login). - Hardware virtualization (
/dev/kvm).
Optional host bits, copied in if present and skipped with a warning if not:
/usr/local/bin/dwmand/usr/local/bin/dwm-grid— the VM's window manager is your local dwm build, not a repo package. Without it the VM has no WM (startxwill fail); build andmake installdwm on the host first.~/.local/bin/claude— Claude Code's binary is copied in (no credentials; see below).~/.config/alacritty/alacritty.toml— carried in so the VM's terminal matches yours.
Usage
Building needs root; launching (with the GUI) does not. Run the build under
sudo, then launch the VM as your normal user so the window opens on your display.
sudo ./build-vm.sh <vm-name> # build a fresh VM disk (root)
./build-vm.sh <vm-name> # launch it — GUI, NO sudo
sudo ./build-vm.sh <vm-name> --run # build, then auto-launch (drops to you for the GUI)
Each VM lives in its own subfolder under ~/prog/agent-vm/<name>/. Building
chowns that folder to you, so the no-sudo launch just works. KVM runs non-root
because /dev/kvm is world-accessible (0666).
Examples
sudo ./build-vm.sh coder # build "coder"
./build-vm.sh coder # boot it (no sudo) — console; run `startx` inside for dwm
sudo ./build-vm.sh scratch --run # build + boot in one go
To rebuild a VM fresh (picking up newer packages), delete its folder first:
rm -rf ~/prog/agent-vm/coder && sudo ./build-vm.sh coder
Accessing a running VM
Ports are forwarded from the host to the guest:
| Host | Guest | Purpose |
|---|---|---|
localhost:2222 |
:22 |
SSH |
localhost:8080 |
:80 |
Web |
ssh -p 2222 arch@localhost # key login (password auth is disabled)
Credentials: user arch / password arch, root / root. The arch user
has passwordless sudo and is in the docker group. SSH accepts your key only
(password auth off, and root login refused); the passwords are only for su and for
logging in on a tty other than tty1 — tty1 autologins as arch, so the GUI
console needs no password.
Configuration
Override via environment variables:
| Variable | Default | Meaning |
|---|---|---|
DISK_SIZE |
40G |
virtual disk size |
VM_CPUS |
8 |
vCPUs |
VM_MEM |
4096 |
RAM (MB) |
HOST_CACHE |
/var/cache/pacman/pkg |
package source |
DISK_SIZE=60G VM_MEM=8192 VM_CPUS=8 sudo ./build-vm.sh big --run
What's in the VM
Fresh Arch with a coding-oriented toolset (edit the PACKAGES array to change it):
- Desktop: Xorg, dwm +
dwm-grid(copied from the host, not a package),dmenu,alacritty,xclip,xdotool,maim, Iosevka fonts - Dev:
base-devel,git,cmake,bear,zig,nodejs/npm,vim,nano, plusfzf,jq,ranger,lf,ncdu,mc,unzip,wget,rsync,age - Containers:
docker,docker-compose,lazydocker(service enabled) - AI/LLM:
opencode, plus Claude Code (binary copied from the host — you log in inside the VM; credentials are deliberately not copied, since the OAuth refresh token is single-use and sharing it would log the host out) - Misc:
firefox,htop/btop,fastfetch
Enabled services: sshd, systemd-networkd (DHCP), systemd-resolved,
getty@tty1 (with an autologin drop-in for arch), docker.
The VM boots to a console, not a desktop — X is not started automatically. Run
startx on tty1 to get dwm. The guest's own ~/README.md (this repo's
USER_README.md, installed at build time) documents the keybindings.
How the build works (13 steps)
All against a loop-mounted raw image on a tmpfs — the VM never boots during the build.
- Disk — create a sparse raw image in RAM (tmpfs), attach it with
losetup, format the whole device as ext4 (no partition table → the VM usesroot=/dev/vda). - Skeleton + binds — create runtime dirs and
rbind-mount/proc /sys /dev /runso pacman's install scriptlets canchroot(see Safety below). - Seed pacman — copy the host's
gnupgkeyring,mirrorlist, and sync DBs into the image (fresh, and skips the slow keyring init). - Install —
pacman --root <mnt> --cachedir <host cache> -S <packages>: today's versions, zero download. The mkinitcpio hook is stubbed out for the duration (step 5 rebuilds the initramfs anyway). - initramfs — write a virtio
mkinitcpio.conf(noautodetect) and regenerate the singledefaultpreset, so the image can see the QEMU virtio disk. - Identity — fstab (root by UUID), hostname, hosts,
C.UTF-8, UTC, empty machine-id (systemd generates a unique one on first boot). - Users — create
arch(wheel + docker), set passwords, NOPASSWD sudo. - SSH — install your pubkey as
authorized_keys, disable password auth and root login. - Network + services — systemd-networkd DHCP profile, then
systemctl --root enable …. - Claude Code — copy the host's
claudebinary in and put~/.local/binonPATH(no credentials — log in inside the VM). - Host binaries — copy
dwmanddwm-gridfrom the host's/usr/local/bin. - Console + session — tty1 autologin drop-in,
~/.xinitrc(background, dwm status clock,exec dwm), the host's alacritty config, andUSER_README.mdas the guest's~/README.md; thenchown -Rthe whole home to uid 1000. - Extract + finalize — copy
vmlinuz/initramfs.imgout of the image (for-kernelboot), kill the chroot's gpg-agent, unmount,qemu-img convertthe raw image into a compact sparse qcow2, then detach the loop and drop the tmpfs.
Then QEMU boots it directly: -kernel vmlinuz -initrd initramfs.img -append "root=/dev/vda rw console=tty0" with virtio disk/net/rng, GTK display, and the
port forwards.
Files produced per VM
~/prog/agent-vm/<name>/
├── <name>.qcow2 # the VM disk
├── vmlinuz # kernel, for -kernel direct boot
└── initramfs.img # virtio initramfs
Safety (important)
The build mounts host filesystems into the image to run install scriptlets. On
systemd, mounts are shared by default, so a naive recursive unmount can
propagate back to the host and tear down real API filesystems like /dev/shm
and /sys/fs/cgroup — which breaks host GUI apps and systemd. This script guards
against that with two independent layers:
- It re-execs the entire build inside a private mount namespace
(
unshare --mount --propagation private), so nothing it mounts is even visible to the host, and leaked mounts vanish when the process exits. - It marks every bind
--make-rslave, so propagation can only flow host→target, never target→host (the same techniquearch-chrootuses).
Do not remove either guard.
Notes & limitations
- Arch host only — the whole model depends on the host's pacman cache/keyring.
- Disk size: each VM is ~4–5 GB (qcow2 stays sparse).
- Networking: QEMU user-mode (SLIRP) — outbound works; the guest is reachable from the host only via the forwarded ports.
- Delete a VM: stop it, then
rm -rf ~/prog/agent-vm/<name>. - The kernel/initramfs are stored per-VM alongside the disk; deleting the folder removes everything for that VM.
Troubleshooting
A build was interrupted / leftover loop device. The build uses losetup and
cleans up after itself even on error (it kills the chroot's gpg-agent, unmounts,
and detaches the loop). If a build was hard-killed and you see a leftover loop
still attached to a .build.raw (deleted) file:
losetup -a | grep agent-vm # see any strays
# a lingering gpg-agent from the killed build can hold the mount — kill it first:
sudo pkill -9 gpg-agent dirmngr keyboxd
sudo losetup -d /dev/loopN # then detach (or `losetup -D` for all)
Also remove the half-built folder: sudo rm -rf ~/prog/agent-vm/<name>.
This project used
qemu-nbdin an earlier version; it was replaced with loop devices because an nbd daemon that dies uncleanly (e.g. when the build's mount namespace tears down) wedges the device into anapparently in usestate that only a module reload clears. Loop devices have no daemon and can't wedge.
GUI window doesn't open on --run: launch it yourself as your user (not sudo):
./build-vm.sh <name>. If DISPLAY still isn't found, run echo $DISPLAY in your
desktop session and pass it: DISPLAY=:0 ./build-vm.sh <name>.