Forge Development VM
forge-dev is an isolated, persistent NixOS development workstation on the
Forge server. It is intended to move Linux builds, repository worktrees and
tool caches off the macOS workstation without mixing development credentials
or agent processes into the public Forge services.
Design
The guest is a normal NixOS system built as an EFI qcow2 image and run by
QEMU/KVM through libvirt. It is not a nixos-rebuild build-vm test runner: the
guest owns persistent disks, boots independently and can later be updated with
the regular NixOS deployment flow.
The initial resource budget leaves capacity for the existing Forge services:
| Resource | Allocation |
|---|---|
| vCPU | 6 of 8 threads |
| Memory | 20 GiB of 31 GiB |
| Root disk | 32 GiB, sparse qcow2 |
| Durable data disk | 100 GiB, sparse qcow2 |
| Disposable cache disk | 120 GiB, sparse qcow2 |
The VM uses 10.78.0.10/24 behind the host-only br-dev bridge. Forge
provides outbound NAT. SSH is proxied only from WireGuard
10.77.0.1:2222; the workstation does not need a route to the guest subnet.
Encryption Boundary
The guest root disk and durable data disk live inside the 150 GiB sparse LUKS2 container at:
/var/lib/forge-vms/forge-dev.luks
The container must be unlocked manually after a Forge reboot. The VM is not configured for autostart because it must never fall back to writing durable state outside the encrypted mount. This protects the powered-off guest disks, including its Nix store, home directory, Git credentials and Docker state. It does not protect a running guest from the Forge root administrator.
The cache disk lives under /cache/forge-vms/forge-dev and is deliberately
unencrypted and reconstructible. Do not place credentials, worktrees or
authoritative state there.
The guest also has a persistent software TPM 2.0 using QEMU's tpm-crb model
and libvirt-managed swtpm. Its state is stored under the encrypted durable
VM directory, so the TPM identity survives guest reboots and domain
redefinition without leaking onto the host root filesystem. This is suitable
for TPM integration, sealing and measured-boot experiments. It is not a
hardware root of trust: a Forge root administrator can still inspect or alter
the emulator and a running guest.
The guest's Ed25519 SSH host key is stored on the encrypted durable data disk
under /srv/dev/ssh. Replacing the rebuildable root disk therefore does not
change the SSH identity. The disk initializer carries the temporary first-boot
key into that directory when it mounts a blank data disk for the first time.
Host Deployment
First evaluate both configurations without building them:
nix eval --raw .#nixosConfigurations.forge.config.system.build.toplevel.drvPath
nix eval --raw .#nixosConfigurations.forge-dev.config.system.build.toplevel.drvPath
nix eval --raw .#nixosConfigurations.forge-dev.config.system.build.images.qemu-efi.drvPath
Test the host generation before making it persistent:
nix run nixpkgs#nixos-rebuild -- test \
--flake .#forge \
--build-host roelc@10.77.0.1 \
--target-host roelc@10.77.0.1 \
--elevate sudo \
--use-substitutes \
--print-build-logs
Verify that the public services remain healthy and that the new host surfaces exist:
ssh roelc@10.77.0.1 systemctl status libvirtd.service forge-dev-ssh-proxy.socket
ssh roelc@10.77.0.1 ip address show br-dev
ssh roelc@10.77.0.1 sudo virsh --connect qemu:///system list --all
After acceptance, repeat the rebuild with switch.
One-time Bootstrap
Initialize the encrypted host storage interactively. This command creates and
formats only /var/lib/forge-vms/forge-dev.luks; it refuses to overwrite an
existing container:
ssh -t roelc@10.77.0.1 sudo forge-dev-storage-initialize
The initializer creates the first LUKS2 keyslot with the primary passphrase. Before placing data in the filesystem, add the separately stored recovery passphrase to a second keyslot:
ssh -t roelc@10.77.0.1 \
sudo cryptsetup luksAddKey /var/lib/forge-vms/forge-dev.luks
Enter the primary passphrase when asked for an existing passphrase, then enter
the recovery passphrase as the new passphrase. Confirm that two keyslots are
enabled with cryptsetup luksDump; its output does not contain either
passphrase. Lock the storage and test forge-dev-storage-unlock once with each
passphrase before continuing:
ssh roelc@10.77.0.1 sudo forge-dev-storage-lock
ssh -t roelc@10.77.0.1 sudo forge-dev-storage-unlock
ssh roelc@10.77.0.1 sudo forge-dev-storage-lock
ssh -t roelc@10.77.0.1 sudo forge-dev-storage-unlock
Only after both keyslots work, create a LUKS2 header backup in Forge's tmpfs, copy it directly into the prepared encrypted APFS disk image, and compare its SHA-256 checksum on both sides. Replace the destination volume name below with the actual mounted image path:
ssh roelc@10.77.0.1 sudo cryptsetup luksHeaderBackup \
/var/lib/forge-vms/forge-dev.luks \
--header-backup-file /run/forge-dev.luks.header
ssh roelc@10.77.0.1 sudo chown roelc:users /run/forge-dev.luks.header
ssh roelc@10.77.0.1 sudo chmod 0400 /run/forge-dev.luks.header
ssh roelc@10.77.0.1 sha256sum /run/forge-dev.luks.header
scp roelc@10.77.0.1:/run/forge-dev.luks.header \
"/Volumes/Forge LUKS Header/forge-dev.luks.header"
shasum -a 256 "/Volumes/Forge LUKS Header/forge-dev.luks.header"
ssh roelc@10.77.0.1 rm /run/forge-dev.luks.header
The header backup contains keyslot metadata and must stay encrypted at rest.
Restoring it replaces the current header and keyslots, so resolve and verify
the exact LUKS container path before using cryptsetup luksHeaderRestore.
Neither normal unlock nor recovery requires storing a passphrase on Forge.
Build the guest image directly in Forge's Nix store. From a clean, committed checkout, use the explicit Linux check output:
nix build \
--eval-store auto \
--store ssh-ng://roelc@10.77.0.1 \
--no-link \
--print-out-paths \
.#checks.x86_64-linux.forge-dev-image
During local development, use a path: URL so new untracked configuration
files are included. Keep evaluation in the workstation store: otherwise Nix
performs thousands of remote-store calls and may upload the complete dirty
source tree, including the repository's large Git object store.
image="$(nix build \
--eval-store auto \
--store ssh-ng://roelc@10.77.0.1 \
--no-link \
--print-out-paths \
"path:$PWD#nixosConfigurations.forge-dev.config.system.build.images.qemu-efi")"
ssh roelc@10.77.0.1 sudo forge-dev-vm-bootstrap "$image"
The bootstrap command copies the immutable root image into encrypted storage, grows it to 32 GiB, creates the data and cache disks, defines the libvirt domain and starts it. It also creates the persistent TPM state directory inside the encrypted mount. It does not replace an existing root or data disk.
Add this workstation entry to ~/.ssh/config:
Host forge-dev
HostName 10.77.0.1
Port 2222
User dev
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
The first SSH login may report that /srv/dev/home/dev does not exist because
the guest disks are still blank. Initialize only the two fixed virtio disks:
ssh -t forge-dev sudo /run/current-system/sw/bin/forge-dev-initialize
Reconnect after initialization. The development home, projects and Docker state are then on the encrypted durable disk.
Confirm the TPM 2.0 device from the guest with:
test -c /dev/tpmrm0
tpm2_getcap properties-fixed
Daily Operation
After a Forge reboot, unlock the storage and start the VM:
ssh -t roelc@10.77.0.1 sudo forge-dev-storage-unlock
cmux ssh forge-dev --no-forward-agent
Agent and Unix-domain socket forwarding are disabled. Provision a dedicated
GitHub credential inside the encrypted guest instead of forwarding the
workstation SSH agent. The dev account cannot obtain general sudo access, but
Docker group membership is root-equivalent inside this isolated guest.
cmux 0.64.22 uses an SSH remote TCP forward for its authenticated relay. The
guest therefore permits remote TCP forwarding only; local TCP, agent,
StreamLocal, X11 and tunnel forwarding remain disabled. GatewayPorts no
forces the relay listener onto guest loopback. Native Mac ssh -L access stays
disabled until its destinations and PermitOpen policy are defined.
Before deliberately detaching the encrypted storage, shut down the VM and lock it with:
ssh roelc@10.77.0.1 sudo forge-dev-storage-lock
The lock command refuses to unmount storage if the guest does not shut down cleanly within 90 seconds.
Interactive Shell Baseline
New dev sessions use zsh with Starship, completion, autosuggestions, syntax
highlighting, persistent history and integrations for direnv, fzf and zoxide.
The guest also includes a small workstation-style CLI baseline: bat, eza,
yazi, lazygit, jj, the workstation Nixvim package, tldr, atac,
comma and common inspection utilities.
This baseline is intentionally separate from repository toolchains. Nixvim's bundled language servers and helpers are treated as guest-wide editor infrastructure. Repository runtimes, generators, linters and test tools should still come from each repository's devenv. Long-lived shell persistence through tmux is not part of the initial shell configuration.
Pilot Workloads
Start with SecretSpec because its devenv definition owns the multi-language
toolchain and exercises the remote Nix store without requiring Kubernetes:
mkdir -p ~/projects
cd ~/projects
git clone https://github.com/adfinis-forks/secretspec.git
cd secretspec
devenv test
Then clone a clean OpenBao Operator worktree. Its external prerequisites are installed in the guest, while repository-managed tools remain pinned by the project:
cd ~/projects
git clone https://github.com/dc-tec/openbao-operator.git
cd openbao-operator
make bootstrap
make doctor
make test-ci
Use make ci-core only after the first three checks pass. It builds and scans
multiple container images and is the useful stress test for the VM's CPU,
memory, Docker storage and cache behavior. Kind-based E2E is a later test, not
part of the initial acceptance gate.
Record wall time, peak memory, durable-disk growth and cache-disk growth for both repositories before deciding whether to change the VM allocation or move more workstreams.
Recovery
The LUKS passphrase is not stored in Nix, the repository or the Forge host. Keep it in the password manager. The VM is initially a migration aid rather than a backup authority: repository branches must still be published, and important unpublished work needs an explicit backup policy before the Mac copy is removed.
The root disk is replaceable from the forge-dev-image output. The durable
data disk, including the SSH host key, is not replaceable and is not included
in the existing Forge Restic job. The cache disk can be deleted and recreated
at any time.