All posts

Setting Up an Arch VM for Graphic-less Work + Docker

23 minutes to read

Provisioning a headless Arch Linux VM under libvirt/QEMU, reaching it over SSH and serial console, and sandboxing a YOLO-mode coding agent inside it with Docker and a filtering egress proxy.

Main Commands

Quick reference for day-to-day VM lifecycle management:

See the full setup guide below for first-time provisioning.

bash
virsh --connect qemu:///system list --all
virsh --connect qemu:///system start arch-sandbox
virsh --connect qemu:///system domifaddr arch-sandbox # confirm the IP below still matches, as DHCP can hand out a different one

virsh --connect qemu:///system shutdown arch-sandbox
virsh --connect qemu:///system destroy arch-sandbox

virsh --connect qemu:///system undefine arch-sandbox --remove-all-storage

virt-viewer --connect qemu:///system arch-sandbox
sudo virsh console arch-sandbox

ssh arch@192.168.122.50
# In case SSH refuses to connect given old trust
ssh-keygen -R 192.168.122.50
# With keys set up already
ssh arch-sandbox

# Mount VM's container projects directory
sshfs arch-sandbox:/home/arch/agent-sandbox/projects /home/user/Documents/virtual-machines/arch/vm-projects-mirror/
# Unmount to stop watching / editing from the host's side
fusermount3 -u /home/user/Documents/virtual-machines/arch/vm-projects-mirror/
# Make an efficient copy of the current state of the containerized projects
rsync -avz arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/arch/vm-projects-copy/

Step-by-Step Setup Guide

1. Install Virt-Manager and Libvirt on Arch (host), then manage ufw

1. Install and Set Up the QEMU and Virtualization Packages:

bash
sudo pacman -S qemu-desktop libvirt virt-install virt-viewer dnsmasq iptables-nft openbsd-netcat sshfs fusermount3

Enable and start the libvirtd service:

bash
sudo systemctl enable --now libvirtd

To run virtualization commands without sudo, add your user to the libvirt group:

bash
sudo usermod -aG libvirt $USER

Log out and log back in (or run newgrp libvirt) for group membership to take effect.

Ensure libvirt's default NAT network is running so the VM gets network access:

bash
sudo virsh net-start default
sudo virsh net-autostart default

2. Allow VM Traffic Through the Host Firewall

If your host runs a firewall, the VM won't reach the network at all, even though libvirt's NAT is running. It'd fail in two different ways depending on which chain is blocking it:

  • INPUT: libvirt's dnsmasq (serving DHCP and DNS to the VM) listens directly on the host, so DHCP requests and DNS-over-dnsmasq lookups need to reach the host itself. DHCP times out.
  • FORWARD: the VM's actual internet-bound traffic (HTTP, HTTPS, everything pacman needs) is routed, not delivered to the host. Block this and ping can look deceptively fine (simple ICMP-through-NAT sometimes still finds a path) while every TCP connection; including pacman -Sy during archinstall, silently times out with no useful error.

If your host uses ufw (confirm with sudo ufw status), fix both from the host. The INPUT side only needs to be open enough for dnsmasq itself: DHCP (UDP/67) and DNS (53, tcp+udp). Anything wider also lets the VM reach whatever else happens to be listening on 0.0.0.0 on one's host, which works against the whole point of sandboxing it.

bash
sudo ufw allow in on virbr0 to any port 67 proto udp
sudo ufw allow in on virbr0 to any port 53
sudo ufw route allow in on virbr0
sudo ufw route allow out on virbr0
sudo ufw reload

Confirm with sudo ufw status numbered: you should see 67/udp on virbr0 and 53 on virbr0 under ALLOW IN, and Anywhere on virbr0 under both ALLOW FWD entries.


2. Spin Up the Isolated VM

2.1 Download the ISO and Create the VM

Arch doesn't offer a netinstall URL like Debian, so grab the ISO first and move it to /var/lib/libvirt/images/, because although virt-install runs under sudo, the QEMU process itself runs as the unprivileged libvirt-qemu user, which can't read files inside your home directory (typically mode 700), hence permission errors at boot.

The commands below call /usr/bin/python3 /usr/bin/virt-install explicitly rather than plain virt-install: if you use mise, its shimmed python3 shadows the system one on $PATH and lacks the gi (PyGObject) bindings these libvirt tools use, so virt-install / virt-xml would fail.

bash
curl -O https://geo.mirror.pkgbuild.com/iso/latest/archlinux-x86_64.iso
sudo mv archlinux-x86_64.iso /var/lib/libvirt/images/
bash
sudo /usr/bin/python3 /usr/bin/virt-install \
  --name arch-sandbox \
  --memory 3072 \
  --vcpus 4 \
  --disk size=32,format=qcow2 \
  --os-variant archlinux \
  --network network=default \
  --graphics vnc \
  --cdrom /var/lib/libvirt/images/archlinux-x86_64.iso

Then open a graphical console into it:

bash
virt-viewer --connect qemu:///system arch-sandbox

2.2 Set the Keyboard Layout and Console Font

bash
loadkeys <keyboard-layout>
setfont sun12x22

2.3 Verify Network Connectivity

This will most probably fail so far:

bash
ping -c 3 8.8.8.8
ip addr show

2.4 Network Configuration and Archinstall

If DHCP doesn't get an address (confirm the interface name first with ip link, typically enp1s0), configure it manually; these commands need to be run again after every reboot until the OS install completes. Once networking is up (whether it came up via DHCP or you had to configure it here)

bash
dhcpcd -1 enp1s0

Once the network works, launch installer:

bash
archinstall
Suggested archinstall Options for This Sandbox
  • Archinstall language: English
  • Locales:
    • Keyboard layout: <keyboard-layout>
    • Locale language: en_US.UTF-8
    • Locale encoding: UTF-8
    • Console font: sun12x22
  • Mirrors & repositories: Selected mirror regions:
    • <given-countries>
  • Disk configuration: best-effort default, ext4, no LVM.
  • Swap:
    • Swap on zram: Enabled
    • Compression algorithm: zstd
  • Bootloader: GRUB
  • Kernels: linux
  • Authentication: set root password, and create arch user with sudo privileges
  • Profile: Server (with Docker and SSH)
  • Applications:
    • Firewall: ufw
  • Network configuration: Copy ISO network configuration to installation
  • Pacman:
    • Color: true
  • Additional packages: (none)
  • Timezone: <given-timezone>
  • Automatic time sync (NTP): Enabled

Exit archinstall and do a shutdown -h now. Then start the VM again.

2.5 Open the Guest Firewall for SSH

The installed system already has ufw active. It now needs an SSH exception, since the default-deny ruleset otherwise blocks access. On the VM:

bash
sudo ufw allow ssh

One can confirm with something like sudo ufw status verbose (it should show Status: active and 22 ALLOW IN Anywhere).


3. Set Up SSH Access from the Host

With the VM reachable, the next step happens entirely on one's host machine: generate (or reuse) a key pair, make sure it's backed up, and configure passwordless access to the sandbox.

3.1 Confirm the VM's Current IP Address

DHCP doesn't guarantee the same address on every installation. The rest of this guide uses 192.168.122.50 as a running example, but check first:

bash
virsh --connect qemu:///system domifaddr arch-sandbox

If that comes back empty or looks stale, fall back to the DHCP lease table directly:

bash
virsh --connect qemu:///system net-dhcp-leases default

3.2 Generate an SSH Key Pair (One-Time Setup)

If you don't have an SSH key pair on the host machine yet, create one:

bash
ssh-keygen -t ed25519 -C "$USER@$(hostname)"
  • Add the ~/.ssh/VMs_ed25519 path.
  • The passphrase is optional; leaving it empty enables fully non-interactive logins.

3.3 Back Up Your Keys

Your private key (id_ed25519, no .pub suffix) cannot be regenerated from the public key, so if it's lost you have to create a brand-new pair and re-distribute the new public key to every host that trusted the old one; therefore, treat it accordingly:

  • Back it up somewhere durable and separate from this machine: your normal backup routine, an encrypted external drive, or a password manager are all reasonable choices. The point is that a single disk failure shouldn't take out both the live key and its only copy.
  • Never store the private key unencrypted in a cloud-synced folder, email it to yourself, or commit it to a git repository.
  • If you ever suspect the private key has leaked, treat it as compromised: generate a new pair and replace it in every authorized_keys file it was added to.
Verify the Key Pair & Lock Down Permissions
bash
ls -l ~/.ssh/id_ed25519*
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

3.4 Create an SSH Config Alias for the VM

On your host workstation, edit ~/.ssh/config to create a quick alias for the sandbox:

text
Host arch-sandbox
    HostName 192.168.122.69
    User arch
    IdentityFile ~/.ssh/VMs_ed25519

3.5 Copy Your Key to the VM

Copy your SSH public key to the VM so you can log in without typing passwords:

bash
ssh-copy-id -i ~/.ssh/VMs_ed25519.pub arch-sandbox

3.6 Connect

Connect using the host name

bash
ssh arch-sandbox

Or, this works whether or not you've set up the alias above:

bash
ssh arch@192.168.122.50

4. Provision the VM

With SSH access running:

4.1 Install Dependencies and Development Tooling

bash
sudo pacman -Syu rsync base-devel docker-compose nvim unzip ripgrep fzf tmux github-cli mise starship eza zoxide git bat btop lazygit lazydocker fd yazi # with font 47

# One needs to initialize mise on .bashrc and/or .bash_profile files for the shims to be available
mise use --global node@latest
mise use --global go@latest

4.2 Set Up LazyVim

bash
# required
mv ~/.config/nvim{,.bak}
# optional but recommended
mv ~/.local/share/nvim{,.bak}
mv ~/.local/state/nvim{,.bak}
mv ~/.cache/nvim{,.bak}

git clone https://github.com/LazyVim/starter ~/.config/nvim

rm -rf ~/.config/nvim/.git

4.3 Copy Dotfiles and Config from the Host

From the host, copy over one's local files, change the paths to leave the VM's state as you'd like to use it:

bash
scp -r ~/.config/nvim/ arch-sandbox:~/.config/

scp -r ~/Documents/virtual-machines/arch/.config/yazi arch-sandbox:~/.config/
scp ~/Documents/virtual-machines/arch/.config/starship.toml arch-sandbox:~/.config/

scp ~/Documents/Arch/arch/.inputrc arch-sandbox:~/
scp -r ~/Documents/Arch/arch/.bashrc.d arch-sandbox:~/
scp ~/Documents/Arch/arch/.bashrc arch-sandbox:~/

4.4 Serial Console Access

Enable a serial getty on the VM so you can reach it via virsh console even without SSH, and use it without graphics and video devices:

bash
sudo systemctl enable --now serial-getty@ttyS0.service

Configure GRUB to output to serial too. This covers the kernel's own console, but GRUB itself has a separate, earlier terminal setting that this doesn't touch (see step 4.5 below for why that distinction matters once there's no video device at all). The input/output ones already exist in Arch's default file, just commented or set to console.

GRUB_SERIAL_COMMAND is a new line, matching the 115200 baud used on the kernel side so both consoles agree on a speed.

Edit:

bash
sudoedit /etc/default/grub

With:

ini
GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 quiet console=tty0 console=ttyS0,115200"

# ...

GRUB_TERMINAL_INPUT="console serial"

# ...

GRUB_TERMINAL_OUTPUT=serial
GRUB_SERIAL_COMMAND="serial --speed=115200"

Then update the GRUB config:

bash
sudo grub-mkconfig -o /boot/grub/grub.cfg

Once that service is running inside the guest, test virsh console arch-sandbox from your host terminal. Hit Enter once or twice, and you'll see the login prompt. Close with Ctrl + ].

4.5 Go Headless: Remove the Graphical Console

Once SSH (and the serial console above) are working, the VNC <graphics> device and the emulated <video> GPU aren't needed day-to-day.

The GRUB serial settings from step 4.4 are what makes removing <video> specifically safe: without them, GRUB's own boot menu (before the kernel even loads) defaults to a graphical terminal that needs a virtual GPU, hanging the moment it's gone. Removing <graphics> alone never hits this, since that only drops the remote-display (VNC) transport, not the GPU itself.

bash
virsh --connect qemu:///system shutdown arch-sandbox

sleep 10

sudo /usr/bin/python3 /usr/bin/virt-xml arch-sandbox --remove-device --graphics all
sudo /usr/bin/python3 /usr/bin/virt-xml arch-sandbox --edit --video model=none

virsh --connect qemu:///system start arch-sandbox

virt-viewer / VNC will no longer connect after this.


5. Sandbox Further with Docker

The VM already isolates the agent from the host; this adds a second layer inside the VM itself, so a coding agent running in YOLO mode (auto-approving its own actions, e.g. Claude Code's --dangerously-skip-permissions) can't wander outside the one project directory it's meant to touch, or phone home to arbitrary destinations, even if it tries.

5.1 Why Bother, If the VM Is Already Isolated?

The VM's job is protecting the host, aka. Your real machine, your other projects, your credentials. It does nothing to contain the agent within the VM: without a container, a YOLO-mode agent can read ~/.ssh, rewrite your shell config, install arbitrary packages system-wide, or fill the available disk.

Docker adds a second, tighter boundary around the agent process itself; the VM stays the blast-radius limit for anything that escapes the container, and the container is what keeps day-to-day agent mistakes from ever reaching that limit (within reason and depending on how containers are made).

Docker is process isolation, not virtualization. A container isn't a separate machine with its own kernel: it is a normal process on the host OS made to look isolated by combining a few Linux kernel features:

  • Namespaces: give the process its own private view of things that are normally global: its own PID tree (so it can't see or signal other processes), its own network stack (interfaces, routing table, ports), its own mount table (so it sees a different filesystem root), its own hostname. This is what makes a container feel like a separate box even though it isn't one.
  • cgroups: the accounting/limiting layer which caps how much CPU, memory, and PIDs a process tree can consume. This is what mem_limit, cpus, and pids_limit in the docker-compose.yml map to.
  • Union/overlay filesystem; a container's filesystem is the image's read-only layers plus one writable layer on top, per container. read_only: true removes that writable layer entirely; tmpfs: /tmp adds back one memory-backed writable exception.
  • Capabilities: Linux root privilege is actually a bundle of dozens of separate powers (mount filesystems, load kernel modules, manage raw sockets, etc.). cap_drop: ALL strips all of them, so even a process with UID 0 inside the container won't do most of what "root" normally implies.
  • seccomp: Docker applies a default profile that blocks a chunk of the more dangerous syscalls regardless of capabilities.

There's no hypervisor, no separate kernel instance, no hardware-level memory isolation. The "wall" around a container is entirely made of kernel bookkeeping: namespaces hiding things, cgroups throttling things, capabilities / seccomp refusing certain calls. If there's an issue in how any of that bookkeeping is enforced (a kernel exploit, a capability left on that shouldn't have been, a bad bind-mount), the process is no longer meaningfully contained.

We do not want agents to read the VM's filesystem directly, hit the VM's real network stack instead of the internal, true Docker network, etc. The internal/egress network split and the proxy allowlist are themselves just Docker-managed network namespaces and iptables rules living inside the VM's kernel, so they're also void if an escape happens, since whatever broke out has the same kernel access needed to reconfigure them D:

The VM layer is enforced by KVM/QEMU hardware virtualization, a fundamentally different and much stronger boundary where the guest has its own kernel, its own memory space managed by the hypervisor, and no visibility into the host's real hardware except through the narrow, defensively-implemented virtio devices exposed. Breaking out of a VM to the host requires a hypervisor-level vulnerability, which is a rarer and harder bug class than a container escape. So the two layers aren't equal walls stacked for redundancy.

5.2 Directory Layout and the Agent Image

Create a working directory for the setup.

bash
mkdir -p ~/agent-sandbox
cd ~/agent-sandbox

Write Dockerfile.agent: a minimal image with just Node.js and git, running as a non-root user:

dockerfile
FROM node:22-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
      git ca-certificates curl build-essential pkg-config \
    && rm -rf /var/lib/apt/lists/*

RUN npm install -g opencode-ai
RUN npm install -g @anthropic-ai/claude-code

# node:22-slim already ships a "node" user at UID/GID 1000
# Remove it and reuse that ID for "agent" instead of taking whatever's next free (1001)
# This lines up with the VM user (arch, UID 1000) that owns the bind-mounted ./projects directory
# A mismatched UID here is a silent write-permission failure, not an obvious one while it looks like the mount itself is read-only.
RUN userdel -r node \
    && groupadd -g 1000 agent \
    && useradd --create-home --uid 1000 --gid 1000 --shell /bin/bash agent

USER agent
WORKDIR /workspace

# A basic local commit identity so `git commit` works out of the box; deliberately not tied to any real account
RUN git config --global user.name "sandbox-agent" \
    && git config --global user.email "sandbox-agent@localhost" \
    && git config --global init.defaultBranch main

# mise, so the agent can provision whatever language/runtime a given project actually needs at runtime
RUN curl https://mise.run | sh
ENV PATH="/home/agent/.local/bin:/home/agent/.local/share/mise/shims:$PATH"

Check id -u for the VM user that owns ~/agent-sandbox first (arch is UID 1000 on a fresh archinstall); if it is something other than 1000, use that value in both useradd/groupadd above instead.

build-essential and pkg-config are there preemptively: the most common reason a project's own npm install / pip install fails inside a minimal image is a native extension trying to compile and finding no compiler. mise handles the "what does this project need to actually run" question, expanded on in step 5.9.

5.3 Restrict Egress to an Allowlist

The agent needs to reach APIs, and usually git/package registries too. With a filtering forward proxy, the agent container has no direct route to the internet, but only to a small proxy container, which allows connections solely to an explicit list of hostnames and drops everything else. Since it's just permitting or refusing the TLS CONNECT tunnel by hostname (not intercepting the traffic itself), it doesn't need to for instance break or inspect HTTPS.

Write Dockerfile.proxy:

dockerfile
FROM alpine:latest
RUN apk add --no-cache tinyproxy
COPY tinyproxy.conf /etc/tinyproxy/tinyproxy.conf
COPY allowlist.txt /etc/tinyproxy/filter
EXPOSE 8888
CMD ["tinyproxy", "-d"]

Write tinyproxy.conf:

text
User tinyproxy
Group tinyproxy
Port 8888
Listen 0.0.0.0
Timeout 600
Allow 172.28.0.0/24
FilterDefaultDeny Yes
Filter "/etc/tinyproxy/filter"
FilterExtended Yes

Write allowlist.txt: regex patterns matched against the requested hostname; start narrow and add entries as legitimate requests turn up blocked in the proxy's logs:

text
^(.*\.)?anthropic\.com$
^(.*\.)?claude\.ai$
^(.*\.)?claude\.com$
^(.*\.)?opencode\.ai$
^(.*\.)?github\.com$
^(.*\.)?githubusercontent\.com$
^(.*\.)?npmjs\.org$
^(.*\.)?mise\.run$
^files\.pythonhosted\.org$
^registry\.npmjs\.org$
^pypi\.org$

5.4 Wire It Together and Lock Down Runtime Behavior

Write docker-compose.yml: the agent sits on an internal: true network with no route to the outside world except through proxy, which straddles that network and the normal internet-facing one:

yaml
services:
  proxy:
    build:
      context: .
      dockerfile: Dockerfile.proxy
    networks:
      - internal
      - egress
    restart: unless-stopped

  agent:
    build:
      context: .
      dockerfile: Dockerfile.agent
    networks:
      - internal
    depends_on:
      - proxy
    environment:
      - HTTP_PROXY=http://proxy:8888
      - HTTPS_PROXY=http://proxy:8888
      - NO_PROXY=localhost,127.0.0.1
    volumes:
      - ./projects:/workspace
      - agent-home:/home/agent
    working_dir: /workspace
    read_only: true
    tmpfs:
      - /tmp:exec
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    pids_limit: 256
    mem_limit: 2g
    cpus: "3"
    stdin_open: true
    tty: true

networks:
  internal:
    internal: true
    ipam:
      config:
        - subnet: 172.28.0.0/24
  egress:
    driver: bridge

volumes:
  agent-home:
What each runtime restriction is actually for
  • ./projects:/workspace: the only thing the agent can write to besides scratch space; point this at the project(s) you want it touching, never $HOME or ~/.ssh. As of this guide, we create a directory named "projects" on the directory we're already in. We are mounting this back on the host as well!

  • agent-home volume: covers the whole home directory, since any CLI tool living in this image (claude, opencode and whatever gets added later) tends to want its own writable spot under the container's $HOME. .claude, .local, .config, .cache, .npm, etc. Mounting the one directory means a new tool's config, cache and login-state just works without a new volume entry every time; it's still an isolated Docker-managed volume, not a host path, yet still persists across container restarts which lets us not authenticate and/or add API keys more than once.

  • read_only + tmpfs: /tmp:exec: the container's root filesystem cannot be modified. Only /tmp (memory-backed, wiped on restart) and the two mounts above are writable. The :exec is needed because Docker's default tmpfs mount is noexec, some tools like opencode's native render library extract a .so into /tmp and need to actually run it, which fails with failed to map segment from shared object otherwise. Worth noting as a tradeoff rather than a free fix, as this doesn't meaningfully widen the sandbox's real boundary, since /workspace (a normal bind mount) was never noexec in the first place; an agent that can write files there could already write and run a script.

  • cap_drop: ALL + no-new-privileges: strips every Linux capability (raw sockets, changing ownership, mount, etc.) and blocks any way of regaining privileges even via a setuid binary.

  • pids_limit, mem_limit, cpus: cap a runaway or fork-bombing process from starving the VM.

Fixing write permissions in case things do not work

If a tool inside the container ever reports permission-denied writing under /home/agent, the fix has to come from outside the agent service, as cap_drop: ALL strips CAP_CHOWN (along with other things), so nothing inside this container can chown anything, not even a process running as root (docker compose run --rm --user root agent chown ...).

Use a plain, uncapped one-off container against the same volume instead:

bash
docker volume ls # confirm the exact name, likely agent-sandbox_agent-home
docker run --rm -v agent-sandbox_agent-home:/data alpine chown -R 1000:1000 /data

5.5 Verify the Allowlist Before Trusting It

Before pointing a real agent at this setup, confirm the proxy actually discriminates:

bash
docker compose build
docker compose run --rm agent bash

# Inside the container:
curl -sS -o /dev/null -w "%{http_code}\n" https://api.anthropic.com # expect an HTTP status, not a proxy error
curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # and this one to be refused

exit
# After which (to confirm)
docker compose logs proxy

Check docker compose logs proxy to find out what to add to allowlist.txt when something legitimate gets blocked.

5.6 Run Claude Code in YOLO Mode

Once the allowlist checks out:

bash
docker compose run --rm agent bash

# The very first time, the auth flow will fail and give you a plain web URL, paste it in the host's machine browser to get its resulting code
claude --dangerously-skip-permissions

The container can be torn down and rebuilt at any time (docker compose down && docker compose up -d --build) without losing the project (bind-mounted) or the agent's login session (named volume), although everything else it might have touched is gone.

5.7 Bridge Files Between the VM and the Host

The ./projects:/workspace mount already gets everything the agent touches out of the container and onto the VM's real filesystem, at ~/agent-sandbox/projects/. Getting it from the VM to the host is a second, separate hop.

I use two main approaches; one for live interactions, and another for long-term copy of the files. Also, there is a last option in case you need an efficient loop to have constant duplicates of the files available to the agents.

5.7.1 Mounting the projects Directory: a Two-way System

sshfs gives a normal-looking local folder that's actually a synchronous view of the VM's directory:

bash
sshfs arch-sandbox:/home/arch/agent-sandbox/projects /home/user/Documents/virtual-machines/vm-projects-mirror/

# To then unmount
fusermount3 -u /home/user/Documents/virtual-machines/vm-projects-mirror/

Two things worth knowing:

  • Use the absolute remote path (/home/arch/...), not ~/.... Some sshfs versions fail to resolve a ~ over SFTP with a misleading "No such file or directory," even though the directory is right there.
  • Edits made through the mount reach the VM immediately (it's a synchronous SFTP write, not a copy), and in the reverse direction it works too of course.
5.7.2 A One-off Copy

If one just wants a single file or a snapshot rather than an ongoing view:

bash
scp -r arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/

Or, for a whole tree:

bash
rsync -avz arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/

5.7.3 Copying changes as they happen, without mounting

This is useful for writes to the host if you can spare the drive's space, be it to use more comfortably or to not deal with some of the inconveniences of a FUSE mount:

It needs inotify-tools on the VM.

This inotifywait feed can drive a continuously up-to-date one-way copy instead of only printing events:

bash
# On the host
rsync -avz arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/ # initial copy

ssh arch-sandbox 'inotifywait -m -r -e modify,create,delete,move ~/agent-sandbox/projects' | \
while read -r _; do
  rsync -avz --delete arch-sandbox:~/agent-sandbox/projects/ /home/user/Documents/virtual-machines/vm-projects-copy/
done

Use a directory other than the sshfs mountpoint above for this (~/vm-projects-copy instead of vm-projects-mirror): one is a live mount, the other is a plain copy kept in sync.

Syncthing (bidirectional, paired directly between VM and host, no SSH tunneling needed) is the heavier tool, worth if the sshfs setup above stops being enough.

5.8 (Optional) Bring In Shell Aliases from the VM

To bring in specific aliases without dragging in a whole .bashrc that likely references tools/paths this minimal image doesn't have, one could bind-mount just select files, read-only, in docker-compose.yml:

yaml
volumes:
  - ./projects:/workspace
  - agent-home:/home/agent
  - ~/agent-sandbox/extra/.bash_aliases:/home/agent/.bash_aliases:ro

Path is on the VM, since that's where docker compose runs. Whether this actually gets sourced depends on the base image's default .bashrc source-ing ~/.bash_aliases; node:22-slim's stock one does by default.

For instance, mine has these:

bash
alias c='opencode --auto'
alias ca='claude --dangerously-skip-permissions'
alias x='exit'

5.9 (Optional) Toolchains for Compiling & Testing Project Code

The image built in step 5.2 is deliberately minimal. If the project the agent is editing needs to actually build or run (a Python script, a Go binary, a Rust crate, anything with a compile step), that runtime has to come from somewhere too, and it is worth being deliberate about where:

  • Bake specific versions into Dockerfile.agent: simplest, but fixes what's available up front; adding or bumping a language later means a rebuild, and running several unrelated projects out of the same container means whatever's baked in has to satisfy all of them at once.

  • Let mise provision it at runtime instead: (already installed in step 5.2). Drop a project's normal .tool-versions or mise.toml in /workspace, then from inside the container:

    bash
    mise trust # mise refuses to auto-run config files from a directory it hasn't seen before
    mise install # pulls whatever versions the project's config asks for

This works despite read_only: true on the root filesystem because everything mise writes, the actual downloaded toolchains, shims and cache live under ~/.local/share/mise and ~/.local/bin, both inside /home/agent, which is the one part of the container's filesystem the agent-home volume already makes writable. Nothing needs to change in docker-compose.yml for this to work.

Installing individual language runtimes may need further allowlist.txt entries depending on which one (Node/Python/Go/Ruby versions are typically fetched from their own upstream mirrors), so expect to repeat the "check docker compose logs proxy, add what's legitimately blocked" loop from step 5.3 the first time each new language gets installed.


Either way, one thing mise does not cover is system-level packages (a C compiler, headers, system libraries; build-essential, libssl-dev, etc.) which still come from apt, and those genuinely can't be installed at runtime as the root filesystem is read-only and every capability is dropped.

Do not reach for the Docker socket. A common shortcut to "give the agent somewhere to test things" is mounting /var/run/docker.sock into the container so it can spin up its own throwaway containers. Be reticent: anything with access to the host's Docker socket can trivially mount the host's real root filesystem into a new container and read/write it directly! It is a full escape from every restriction in this guide, not a sandboxed way to run more sandboxes.

If one modifies the image's /home/agent

Docker only populates a named volume from the image's content once, the very first time that volume is created. Every rebuild since then, the image's /home/agent (which has programs installed in it) gets completely shadowed the moment the old, already-existing volume mounts on top of it at container start.

Run docker compose down -v if need be: It will clear up any configs or auth tokens stored so far from previous container runs.