virt-box — Declarative NixOS KVM Appliance for Pi, OpenCode & OpenRig Agents
Zero-custom-software, declarative NixOS KVM virtual machine for any Linux
libvirt/QEMU/KVM host. Inside the VM, Pi
(@earendil-works/pi-coding-agent) (primary), OpenRig (@openrig/cli)
(multi-agent team topologies with first-class runtime: pi), and OpenCode
(anomalyco/opencode) (secondary) agents have full system development
access (compilers, Nix flakes, nix-ld, envfs, rootless Podman, headless
Chromium/Playwright) while remaining isolated from the host filesystem.
Four WebUIs + OpenRig TUI/daemon + native Android apps are exposed securely over Tailscale Serve (no public ports):
| URL (guest tailnet name) | Backend | App |
|---|---|---|
https://virt-box.<tailnet>.ts.net/ (:443) |
Paseo daemon :6767 |
Paseo WebUI + Paseo Android (sh.paseo) |
https://virt-box.<tailnet>.ts.net:8443 |
pi-web-ui :8787 |
xing-shuyin/pi-web-ui browser cockpit |
https://virt-box.<tailnet>.ts.net:8504 |
pi-web :8504 |
@jmfederico/pi-web persistent sessions |
https://virt-box.<tailnet>.ts.net:3000 |
OpenChamber :3000 |
OpenChamber web |
https://virt-box.<tailnet>.ts.net:4096 |
opencode :4096 |
opencode attach + OpenCode Mobile (app.getopencode) |
https://virt-box.<tailnet>.ts.net:7681 |
ttyd+tmux :7681 |
OpenRig TUI (openrig-tui, touch/mouse, add to Home Screen as PWA) |
https://virt-box.<tailnet>.ts.net:7433 |
OpenRig daemon :7433 |
OpenRig daemon + bundled web UI |
Why separate ports instead of subpaths? WebSocket-heavy SPAs
(paseo, pi-web-ui, pi-web, openchamber) emit root-relative /ws and
/api requests that break under a subpath reverse proxy. Tailscale Serve
multi-port HTTPS avoids this entirely.
Port / data-dir isolation:
xing-shuyin/pi-web-uiand@jmfederico/pi-webboth default toPI_WEB_DATA_DIR=~/.pi-web. This repo isolates them (~/.local/share/pi-web-uion:8787vs~/.local/share/pi-web-jmon:8504).
Node 24 + hybrid NPM + Nix:
@openchamber/webv2.5+ requiresnode >= 24.14.0, and the Pi/Paseo/WebUI npm packages release daily. NixOS providesnodejs_24+nix-ld+ compilers; avirt-box-npm-bootstrapservice installs the exact npm packages into/home/agent/.npm-globalwithout custom Nix derivations.
Repo layout
flake.nix # nixpkgs-unstable + home-manager + sops-nix + disko
nixos/
configuration.nix # guest base: UEFI, qemu-guest, agent user, tailscale, SSH
modules/
dev-environment.nix # nix-ld + envfs + direnv + podman + toolchains + playwright + ttyd/sqlite
secrets.nix # sops-nix -> /run/secrets + agent-secrets.env template
agents.nix # 4 Pi profiles + OpenCode config + tmux/openrig seeds + wrappers
webuis.nix # npm bootstrap + 8 systemd services + tailscale-serve
backups.nix # vdb mount + restic daily (incl. sqlite snapshot) + config auto-push timer
profiles/
pi/{coder,researcher,auditor,devops}/{settings.json,mcp.json,SYSTEM.md}
opencode/opencode.json # base config with {env:VAR} indirection
openrig/pi-squad.yaml # Pi-native OpenRig team topology (runtime: pi seats)
scripts/
host-provision-vm.sh # build image + create disks + virt-install
host-setup-cockpit.sh # cockpit-machines over Tailscale Serve on the host
secrets/
secrets.example.yaml # copy to secrets.yaml, encrypt with sops
Justfile # build-image | create-vm | deploy | setup-host-cockpit | ...
1. Host prerequisites
Any Linux KVM host (Ubuntu/Debian/Fedora/Arch) with Nix installed:
# Fedora example:
sudo dnf install -y nix qemu-kvm libvirt virt-install virt-manager edk2-ovmf swtpm swtpm-tools
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt,qemu,kvm $USER # re-login afterwards
# /etc/nix/nix.conf needs:
# experimental-features = nix-command flakes
# trusted-users = root @wheel <you>
Enter the host dev shell (provides sops, age, ssh-to-age,
nixos-rebuild, just):
nix develop
2. Secrets (sops-nix)
cp secrets/secrets.example.yaml secrets/secrets.yaml
# 1. Put your admin age public key + the VM host key (ssh-to-age) in .sops.yaml:
# ssh-to-age -i /etc/ssh/ssh_host_ed25519_key.pub # run on the VM/host key
# 2. Encrypt:
sops secrets/secrets.yaml # fill in API keys, webui_password, restic_password, ...
Keys supported: anthropic_api_key, openai_api_key, gemini_api_key,
openrouter_api_key, opencode_api_key, deepseek_api_key,
tavily_api_key, firecrawl_api_key, firecrawl_api_url,
searxng_base_url, context7_api_key, webui_password,
tailscale_auth_key, restic_password, github_ssh_private_key.
Secrets decrypt to /run/secrets/* (0400, agent:users) and are rendered
into /home/agent/.config/virt-box/secrets.env, consumed as
EnvironmentFile by every agent service. OAuth token files
(~/.pi/agent/auth.json, ~/.local/share/opencode/auth.json) stay writable
so pi auth / opencode auth refreshes work.
3. Build + provision
cp .env.example .env # adjust sizes/paths; backup disk can live on a 2nd drive
just build-image # nixos-rebuild build-image --image-variant qcow-efi --flake .#virt-box
just create-vm # virt-install q35/UEFI/TPM2/virtio + vda OS + vdb backup disk
virsh list --all
just deploy # nixos-rebuild switch --target-host root@$VM_IP (fast updates)
just setup-host-cockpit # Cockpit :9090 over Tailscale Serve on the host
Defaults: 16 GB RAM, 8 vCPUs, 100 GB OS disk, 100 GB backup disk.
4. Connect clients over Tailscale
Inside the guest, tailscale-serve-webuis.service registers Serve listeners
automatically (zero-touch join via tailscale_auth_key when set). Then:
- Browser: open the URLs in the table above (Paseo
:443, pi-web-ui:8443, pi-web:8504, OpenChamber:3000, OpenRig TUI:7681, OpenRig daemon:7433). The:7681page is a persistenttmux-backed terminal — closing the tab or locking your phone never interrupts the session, and it can be added to the Home Screen as a fullscreen PWA. - Paseo Android (
sh.paseo): Add host → Tailscale directhttps://virt-box.<tailnet>.ts.net(or100.x:6767+ Bearer password). - OpenCode Mobile (
app.getopencode):opencode pair --url https://virt-box.<tailnet>.ts.net:4096, scan in app. - Cockpit (host):
https://<host>.<tailnet>.ts.net:9090for start/stop, reboot, console, and qcow2 snapshots. - SSH fallback: Termux +
tailscale+ssh agent@virt-box+tmux.
Run just pair-mobile for a pairing cheat-sheet.
5. Pi profiles (ppi / PI_CODING_AGENT_DIR)
NixOS seeds four baselines, copied (writable) to ~/.pi/profiles/<name>/:
| Wrapper | Focus | MCPs |
|---|---|---|
pi-coder |
Full dev | codebase-memory, context7, playwright, semgrep, openrig |
pi-researcher |
Web + codebase research | searxng, firecrawl, tavily, context7, codebase-memory |
pi-auditor |
Read-only red-proof QA | semgrep, codebase-memory |
pi-devops |
NixOS/Podman/systemd | codebase-memory, context7, semgrep, openrig |
pi-coder "implement feature X"
ppi use researcher -p "investigate Y" # mutable runtime switching
pi config # live edits persist in ~/.pi/profiles/*
virt-box-sync-profiles # sync runtime edits back to /etc/nixos/profiles
Every profile is also registered in Paseo (~/.paseo/config.json providers
pi-coder/... + daemon.agentProfiles) for one-tap switching in the Paseo
WebUI and Android app.
OpenCode mirrors this with opencode-coder/... wrappers
(OPENCODE_CONFIG + OPENCODE_CONFIG_DIR overlays) and a shared base
~/.config/opencode/opencode.json using {env:VAR} secret indirection.
OpenRig team topologies (runtime: pi) + MCP control plane
NixOS seeds ~/.openrig/specs/pi-squad.yaml (builder + researcher + QA seats,
all runtime: pi) and a touch/mouse-enabled ~/.tmux.conf. The persistent
openrig tmux session (window 0: openrig-tui, window 1: operator shell) is
served over ttyd on :7681; the OpenRig daemon + bundled UI listens on
:7433.
rig up ~/.openrig/specs/pi-squad.yaml --cwd . # boot the Pi squad
rig ps && rig status # inspect seats
pi-coder, pi-devops, and OpenCode all ship the openrig MCP server (rig mcp serve), so from Paseo (:443 / sh.paseo), pi-web-ui (:8443), or
OpenChamber you can just ask: "boot the pi-squad rig and assign task X to
dev-build" — the agent drives rig_up / rig_send / rig_ps itself.
6. Backups
- Data & workspaces (Restic, daily): the secondary disk
(
vdb/virt-box-backup, ext4 labelVIRTBOX_BACKUP) mounts at/mnt/backup.services.restic.backups.virtbox-localbacks up~/.pi,~/.openrig,~/.local/share/opencode,~/.paseo, both pi-web data dirs,~/workspaces, and/etc/nixos(excluding caches/node_modules) encrypted withrestic_password, retaining 7 daily / 4 weekly / 6 monthly. AbackupPrepareCommandtakes an atomic SQLite snapshot (openrig.sqlite→openrig-backup.sqlite) first, so running rigs and the daemon are never interrupted.restic-virtbox-local snapshots restic-virtbox-local restore latest --target /tmp/restore just backup-now - Configs (Git auto-push, hourly):
virt-box-config-sync.timersyncs~/.pi/profiles/*/settings.json|mcp.json|SYSTEM.mdand~/.openrig/specs/*.yamlback to/etc/nixos/profiles/and pushes toorigin/mainwith the deploy key. - Full-VM snapshots: Cockpit → VM → Snapshots, or
just snapshot my-checkpoint.
7. Verification
just validate # bash -n scripts/*.sh + jq empty profiles/**/*.json
just check # nix flake check --no-build + eval toplevel drvPath
Manual: virsh list shows virt-box running → tailscale status shows it
joined → open all seven Serve URLs (:443, :8443, :8504, :3000,
:7681, :7433, :4096) → switch pi-coder…pi-devops in Paseo →
restic-virtbox-local snapshots shows backups on /mnt/backup → from Paseo
or pi-web-ui, ask pi-coder to call rig_ps via the openrig MCP.