Running code agents in an immutable NixOS VM
AI coding agents like Claude Code and Open Code run in your terminal, read and write files, execute commands, and generally do whatever you tell them to. Claude Code is Anthropic’s official CLI agent; Open Code is an open-source alternative that supports multiple model providers (including Anthropic). Both are powerful, and both have full shell access to whatever machine they’re running on — which is a bit concerning if that machine is your daily driver laptop.
This post walks through setting up a code agent inside an immutable NixOS VM on a Fedora host, editing files remotely with Emacs TRAMP, and using git branches so that the agent’s work is immediately testable on other machines.
Why bother with a VM?
A code agent has shell access to whatever machine it’s running on. It can modify files and run any program. If you point it at your laptop’s home directory and say “refactor this project,” it will happily do so, and if something goes wrong, it went wrong on your machine.
A VM gives the agent its own filesystem to work with. The nixos-vm-template project builds immutable NixOS images with a read-only root filesystem and a separate data disk. If the environment gets weird, you can blow it away and recreate it in two commands. Your laptop stays clean.
The tradeoff is that you’re SSH’ing into a VM instead of running locally. With TRAMP this is basically invisible to Emacs, so it hasn’t bothered me.
Install the prerequisites
This example uses a Fedora laptop, but Debian, Arch, or pretty much any other Linux distro with KVM support will work. The only software dependencies you need are libvirt and the Nix package manager:
# Fedora
sudo dnf install nix just git libvirt qemu-kvm virt-manager guestfs-tools edk2-ovmf
sudo systemctl enable --now nix-daemon libvirtd
Create the libvirt-admin user
The build scripts generate sensitive files in the machines/
directory, including SSH host keys. The
README
recommends creating a dedicated user account so these files are
protected from other users on your system:
sudo useradd -m -s /bin/bash libvirt-admin
sudo usermod -aG libvirt libvirt-admin
Switch to this user for all of the remaining steps:
sudo -iu libvirt-admin
Enable Nix flakes for this user:
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
Clone and build
git clone https://github.com/EnigmaCurry/nixos-vm-template \
~/nixos-vm-template
cd ~/nixos-vm-template
The template ships with both a claude profile and an open-code
profile. Both include Node.js, git, Docker, Rust, Python, and various
development tools. The agent itself gets installed via npm on first
login. Pick the one you want and build:
# For Claude Code:
just build claude
just create claude-dev claude 8192 4
# For Open Code:
just build open-code
just create opencode-dev open-code 8192 4
This creates a VM with 8GB RAM and 4 CPUs. The root filesystem is
read-only (immutable), and all mutable state lives on a separate
/var disk. Home directories are bind-mounted from /var/home.
First boot
just start claude-dev # or opencode-dev
just status claude-dev # prints the IP address
just ssh claude-dev # SSH into the VM with the 'user' account
On first login, the shell profile detects that the agent isn’t
installed yet and runs the appropriate npm install automatically
(@anthropic-ai/claude-code or opencode-ai). Both agents need an
API key for your service of choice. Here’s an example for Anthropic:
echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.bashrc
source ~/.bashrc
The Open Code profile also creates a default config at
~/.config/opencode/config.json pointing at Claude Opus 4.5. You can
edit that file to switch models or providers.
Set your git user profile and preferences:
git config --global user.email "you@example.com"
git config --global user.name "Your Name"
git config --global init.defaultBranch master
Create a project repository
Create a fresh repository on GitHub for the agent to work in. This will be the project it has full control over, committing and pushing on your behalf. Go to GitHub and create a new repository (public or private, your choice), then come back here.
Next, generate an SSH key on the VM so it can push to that repo:
ssh-keygen -t ed25519 -C "claude-dev-vm" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub
Copy the public key and add it as a deploy key on the repository:
- Go to your repository on GitHub.
- Navigate to Settings > Deploy keys > Add deploy key.
- Paste the public key, give it a name like “claude-dev VM”, and check Allow write access.
- Click Add key.
Deploy keys are scoped to a single repository, which is a good fit here. The VM only needs push access to the project it’s working on, not your entire GitHub account. If the agent is working on multiple repos, generate a separate key per repo.
Verify the key works:
ssh -T git@github.com
You should see a message confirming authentication. Now clone the repo on the VM:
cd ~
git clone git@github.com:you/your-project.git project
cd project
Run claude or opencode in the project directory.
Editing files with TRAMP
TRAMP (Transparent Remote Access, Multiple Protocols) is Emacs’s built-in facility for editing remote files over SSH. You open a file with a special path syntax and Emacs handles the rest, no FUSE mounts or sync tools involved.
You can read more about how to configure emacs with my emacs config.
(If you don’t want to use Emacs, you can do something pretty similar in VS Code with the Remote Development Extension).
First, add an SSH config entry so you don’t have to remember the IP:
Note: this config should be located in your normal user account, not libvirt-admin
# ~/.ssh/config
Host claude-dev
HostName <ip>
User user
ControlMaster auto
ControlPersist yes
ControlPath /tmp/ssh-%u-%r@%h:%p
(The ControlMaster settings are optional, they enable SSH connection sharing so you won’t have to authenticate as often.)
Now you can open files on the VM directly:
C-x C-f /ssh:claude-dev:/home/user/project/src/main.rs
To get a shell running inside the VM, open a remote shell buffer:
M-x shell RET
When prompted for a directory, enter /ssh:claude-dev:/home/user/.
(Or use vterm if you prefer a proper terminal emulator in a buffer.)
Run your agent in that shell. Now you have it running in the VM, and
your Emacs buffers pointing at the same files it’s modifying. When the
agent writes to a file you have open. In Emacs enable M-x auto-revert-mode and the fill will automatically detect changes and
reload the file.
SSH remote forwarding
Sometimes the agent needs access to a service running on your laptop.
For example, if you have a Traefik dashboard listening on
127.0.0.1:8080, you can expose it to the VM with SSH remote
forwarding:
ssh -R 8080:127.0.0.1:8080 user@claude-dev
This binds port 8080 on the VM to port 8080 on your laptop. Inside the
VM, the agent can now reach the Traefik dashboard at 127.0.0.1:8080
as if it were local. You can add this to your SSH config to make it
persistent:
# ~/.ssh/config
Host claude-dev
...
RemoteForward 8080 127.0.0.1:8080
This is useful for giving the agent access to local dev servers, API
endpoints, or dashboards without exposing them to your network.
Furthermore, the claude and open-code profiles explicitly block
access to RFC 1918 networks,
so if you need the agent to access some service on your LAN, this is
the only way.
The git workflow
The real utility of this setup is the git branching workflow. You work on a dev branch, the agent commits and pushes to it, and you can pull those changes on any other machine to test.
Setup
In the project you cloned earlier, check out a dev branch:
cd ~/project
git checkout -b dev/claude-work
Agent directives
Both Claude Code and Open Code support project-level instruction
files. Claude Code reads CLAUDE.md; Open Code reads
AGENTS.md. Add one (or both) to the project root:
- Always run `git pull` before making any changes.
- When working on a branch other than `master` or `main`, automatically commit
and push changes when done with a task.
The agent reads this file and follows the directives. When you give it a task (“add input validation to the login handler”), it will pull, make the changes, commit with a message describing what it did, and push.
Testing on other machines
On your desktop, or wherever you want to test:
git fetch origin
git checkout dev/claude-work
git pull
# run tests, build, review the diff, whatever
If the tests fail, go back to the Emacs shell buffer and tell the agent what went wrong. It pulls, fixes, commits, pushes. You pull again. This loop works the same way it would with any remote collaborator, except the collaborator happens to be an AI running in a VM on your laptop.
When you’re happy with the branch, merge it however you normally merge branches.
Snapshots
One of the nice things about running in a VM is that you can snapshot the state disk before asking the agent to do anything particularly adventurous:
just snapshot claude-dev before-refactor
If things go sideways:
just restore-snapshot claude-dev before-refactor
This only snapshots the /var disk (which contains home directories,
git repos, and all mutable state). The root filesystem is immutable so
there’s nothing to snapshot there.
Upgrades and profiles
The template uses a layered profile system. Each profile imports the one below it, adding packages and services:
base → vim, curl, htop, git, ripgrep, jq, just
└── core → SSH daemon, user accounts, firewall
└── docker → Docker daemon
└── dev → neovim, tmux, rust, python, docker
├── claude → nodejs, auto-installs claude-code
└── open-code → nodejs, auto-installs opencode-ai
Each agent profile inherits everything from dev, which inherits
from docker, and so on. When you run just build claude or
just build open-code, you get the full stack. Each VM’s selected
profile is stored in
machines/<name>/profile, which is set during just create and read
by just upgrade to know which image to rebuild. To switch a VM to a
different profile, edit that file and put the name of the profile you
want in it (one word, e.g. claude), then run just upgrade <name>.
To add packages, edit the profile file in profiles/. For example, to
add go to a profile:
# profiles/claude.nix (or open-code.nix)
{ pkgs, ... }:
{
imports = [ ./dev.nix ];
environment.systemPackages = with pkgs; [
nodejs
go # add new packages here
];
}
Or create your own profile that imports either agent profile and adds whatever you need. After editing, rebuild and upgrade:
just upgrade claude-dev # build and install new image, preserving /var
Your home directory, git repos, npm globals (including the agent
itself), and everything else on /var survives the upgrade. Only the
read-only root filesystem gets replaced.
Multiple VMs
VMs created from the same base image are thin-provisioned (QCOW2 backing files), so they only store deltas from the shared image. You can spin up multiple VMs — one per project or task — each with their own repos and contexts, without duplicating the full OS image for each one.
Bridged networking
By default VMs use NAT, which means they’re accessible from the host but not from other machines on your LAN. If you want to pull the agent’s commits from a desktop on the same network without going through GitHub, you can use bridged networking:
just create claude-dev claude 8192 4 20G bridge
The VM will get an IP from your LAN’s DHCP server and be directly reachable from other machines.
See more information in the nixos-vm-template README.md
Conclusion
Putting a code agent in an immutable NixOS VM keeps your laptop safe:
the OS is read-only, mutable state is isolated on /var, and snapshots
make rollback trivial. With TRAMP and a branch-based git loop, it
still feels local—and the agent’s work is easy to test anywhere.
You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com
This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️