Last updated: April 2026
Claude Code runs shell commands, edits files, and manages git repositories on your behalf. Running it inside a virtual machine gives you an isolation boundary: if something goes wrong, your host system is unaffected. You can also snapshot the VM and roll back at will.
UTM is a macOS application that provides a convenient frontend for QEMU virtual machines. The setup below creates a Linux VM with one or more shared folders, so you can edit files on your Mac (e.g., in Visual Studio Code) and have Claude Code work on them inside the VM. The examples use Ubuntu, but any Linux distribution will work with minor adjustments.
Download and install UTM, then download an Ubuntu desktop image (ARM64 if you are on Apple Silicon). (A server image works too, and takes up less space, but forces pre-SSH bringup to be done without cut-and-paste.) Create a new VM following the UTM basics guide.
Once the VM is running, install the QEMU guest agent (see UTM Linux guest support for background). On Ubuntu:
sudo apt install qemu-guest-agent
In UTM's VM settings, add a shared directory pointing to the folder on your Mac that contains the files you want Claude Code to work on. UTM exposes the shared directory to the guest via the VirtIO 9p protocol.
For concreteness, the rest of this guide uses /Users/username/src as the shared directory. (Replace username with your actual username throughout.)
Inside the guest VM, create a mount point:
sudo mkdir -p /mnt/utm1
Add the following line to /etc/fstab:
# UTM shared folder (VirtIO 9p) share /mnt/utm1 9p trans=virtio,version=9p2000.L,rw,_netdev,nofail,auto 0 0
Then reload systemd and mount:
sudo systemctl daemon-reload sudo mount /mnt/utm1
Verify that the mount works:
ls /mnt/utm1
You should see your Mac files listed.
On some Ubuntu versions (e.g., 25.04) you may see this error when reloading:
Failed to restart network-fs.target: Unit network-fs.target not found.
This is harmless — the _netdev and nofail options in the fstab entry mean that the mount will still succeed. If the mount did not happen automatically at boot, run:
sudo systemctl daemon-reexec sudo mount /mnt/utm1
If you inspect the mounted files, you will notice that they are owned by UID 501 and GID 20 — the default macOS user and staff group. Your Linux user is typically UID 1000 / GID 1000, so the files appear to belong to someone else:
$ ls -na /mnt/utm1 drwxr-xr-x 501 20 4096 ...
The fix is to use bindfs to create a second mount that remaps the ownership.
sudo apt install bindfs
$ id -u # expect 1000 $ id -g # expect 1000
Also double-check the UIDs on the 9p mount:
$ ls -na /mnt/utm1
Look for the numbers (e.g., 501 and 20) that you will remap below.
Create a directory where the remapped view will appear, e.g.:
mkdir ~/src
Add a second line to /etc/fstab. For example, assuming the ID values above:
# bindfs: remap macOS UID/GID to Linux UID/GID /mnt/utm1 /home/username/src fuse.bindfs map=501/1000:@20/@1000,x-systemd.requires=/mnt/utm1,_netdev,nofail,auto 0 0
The key options:
Apply and mount:
sudo systemctl daemon-reload sudo mount ~/src
Now ~/src should show your files with correct ownership:
$ ls -na ~/src drwxr-xr-x 1000 1000 4096 ...
UTM's UI supports only one shared directory per VM. But it can be useful to have multiple shared directories, e.g., to have more fine-grained isolation rather than exposing an entire directory tree to the VM.
To share more than one directory, the simplest option is to export the second directory over NFS from the Mac. If NFS's higher latency becomes a bottleneck, you can add another 9p share via custom QEMU arguments instead, at the cost of some hassle caused by the macOS sandbox (see Option 2 below).
The examples below share a second host directory /Users/username/shared and mount it at ~/shared on the guest.
NFS is served by nfsd on the macOS host. The guest kernel talks to it over the virtual network, so QEMU never opens the files and UTM's sandbox is not involved.
Add one line to /etc/exports for each directory you want to share:
/Users/username/shared -network 192.168.64.0 -mask 255.255.255.0 -mapall=username
The -network/-mask clause restricts access to UTM's shared-network subnet. Confirm the actual subnet with ifconfig — UTM uses bridge10x interfaces that appear only while a VM is running. -mapall=username squashes every incoming request to your Mac user, so writes from the guest land on the host as your macOS user regardless of the guest's internal UID.
Enable nfsd (which also makes it start at every boot), start it, and re-read the exports on each change:
sudo nfsd enable sudo nfsd start sudo nfsd update
Note that macOS's nfsd serves only NFSv2 and NFSv3; NFSv4 is not supported on the server side. The guest side must therefore mount with vers=3 explicitly, since modern Linux defaults to v4.
Install NFS client support and create the mount points:
sudo apt install nfs-common sudo mkdir -p /mnt/nfs1 mkdir ~/shared
Add two /etc/fstab lines — one for the NFS mount and one for the bindfs remap on top of it (same UID/GID fix as for the 9p share):
192.168.64.1:/Users/username/shared /mnt/nfs1 nfs vers=3,proto=tcp,rsize=65536,wsize=65536,_netdev,nofail,auto 0 0 /mnt/nfs1 /home/username/shared fuse.bindfs map=501/1000:@20/@1000,x-systemd.requires=/mnt/nfs1,_netdev,nofail,auto 0 0
Replace 192.168.64.1 with the Mac's IP on the UTM vmnet (usually the .1 of the subnet). Then:
sudo systemctl daemon-reload sudo mount /mnt/nfs1 sudo mount ~/shared
The -network clause in /etc/exports is nfsd's own access control and is the primary defense against non-VM clients. The macOS Application Firewall (System Settings > Network > Firewall), even when enabled, does not restrict nfsd by subnet — it makes per-application decisions, and nfsd is an Apple-signed daemon that it allows by default. For a dev-machine threat model, the exports restriction alone is usually sufficient.
For defense in depth, or if you sometimes work on untrusted networks, you can add pf rules that block NFS (TCP/UDP 2049) and portmap (TCP/UDP 111) at the packet layer except from the UTM subnet. Create /etc/pf.anchors/com.nfs-restrict:
block in quick proto { tcp, udp } from any to any port { 2049, 111 }
pass in quick proto { tcp, udp } from 192.168.64.0/24 to any port { 2049, 111 }
And load it from /etc/pf.conf.
NFS has noticeably higher per-operation latency than 9p, which shows up in metadata-heavy workloads (git on a large tree, build systems that stat() thousands of files). If this bottlenecks your workflow, either build into a VM-local directory (e.g., set CARGO_TARGET_DIR, GOCACHE, or a CMake out-of-tree build dir), or switch to Option 2.
If NFS latency is a problem even with VM-local build artifacts, the alternative is a second 9p share added via raw QEMU arguments. Performance matches the original 9p share, but you run into a macOS sandbox issue discussed below.
Shares are passed to QEMU as pairs of -fsdev/-device arguments. You can add more of these yourself: in the VM settings, open the QEMU > Arguments page and click "New..." for each entry. For a second share pointing at /Users/username/shared, add the four arguments:
-fsdev local,id=virtfs1,path=/Users/username/shared,security_model=mapped-xattr -device virtio-9p-pci,fsdev=virtfs1,mount_tag=share1
The id (virtfs1) and mount_tag (share1) must both differ from those used by the UI-configured share (typically virtfs0 and share). Screenshots of UTM's Arguments pane, before and after adding the extra share:
In the guest, set up the new mount points:
sudo mkdir -p /mnt/utm2 mkdir ~/shared
And add two /etc/fstab lines, using the new mount_tag (share1) as the 9p source:
share1 /mnt/utm2 9p trans=virtio,version=9p2000.L,rw,_netdev,nofail,auto 0 0 /mnt/utm2 /home/username/shared fuse.bindfs map=501/1000:@20/@1000,x-systemd.requires=/mnt/utm2,_netdev,nofail,auto 0 0
UTM is a sandboxed macOS application, and QEMU can only open host paths for which UTM holds a security-scoped bookmark. macOS issues these when you pick a folder with the "Browse..." button in the Shared Directory UI; paths that appear only in custom QEMU arguments have no bookmark. The sandbox then denies the open and the VM refuses to start, with an error like:
QEMU error: QEMU exited from an error: ... cannot initialize fsdev 'virtfs1': failed to open '/Users/username/shared': Operation not permitted.
This can appear to work initially if macOS has a cached bookmark for the path from an earlier interaction, but reboots, UTM updates, or macOS upgrades may invalidate the cache. The standard workaround is to "prime" the bookmark:
This is fragile: bookmarks can expire between UTM sessions, so you may have to re-prime periodically. If that is unacceptable, use Option 1 (NFS).
UTM's VM display window is serviceable for initial bringup, but for day-to-day use it is much nicer to SSH into the VM from a Mac terminal — you get copy/paste, scrollback, your usual shell setup, and tabs. This is especially useful for running Claude Code.
sudo apt install openssh-server sudo systemctl enable --now ssh
Find the VM's IP on the shared-network interface:
ip addr show
Look for an address on the UTM subnet (typically 192.168.64.x).
Give the VM a convenient name by editing /etc/hosts on the Mac and adding a line like:
192.168.64.5 claude-vm
(Of course, replace the IP with the one from the step above, and the hostname with whatever you want.) UTM's shared-network DHCP assigns stable leases based on the VM's MAC, so the IP should remain valid unless you replace the virtual NIC.
If you do not already have an SSH key on the Mac, generate one:
ssh-keygen -t ed25519
Copy the public key to the VM:
ssh-copy-id username@claude-vm
You should now be able to log in without a password:
ssh claude-vm
Claude Code requires Node.js 18 or later. Install it (and NPM, if necessary) in the VM:
sudo apt install nodejs npm
Then install Claude Code:
npm install -g @anthropic-ai/claude-code
You can now cd into ~/src (or any subdirectory of it) and run claude. It will operate on your shared files within the safety of the VM.