Skip to content

jgpruitt/sandbox

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 

Repository files navigation

Sandboxing Coding Agents with Lima on macOS

A practical guide to running AI coding agents (Claude Code, Codex, Gemini CLI, etc.) in isolated Linux VMs on macOS, with live bidirectional file sharing and per-sandbox Docker daemons.


Table of Contents

  1. Architecture Overview
  2. Prerequisites
  3. Installation
  4. Core Concepts
  5. Creating Your First Sandbox
  6. The Custom Template
  7. Running Agents in Sandboxes
  8. Running Multiple Sandboxes Concurrently
  9. Lifecycle Management
  10. Snapshots
  11. Cloning Sandboxes
  12. Shell Wrapper Script
  13. Editing Sandbox Files with Zed
  14. Resource Tuning
  15. Networking Details
  16. Troubleshooting
  17. Reference

Architecture Overview

 macOS Host
 ┌──────────────────────────────────────────────────────────┐
 │  ~/projects/myapp/          ~/projects/sideproject/      │
 │       │                            │                     │
 │       │ VirtioFS mount             │ VirtioFS mount      │
 │       ▼                            ▼                     │
 │  ┌──────────────────┐     ┌─────────────────┐            │
 │  │  Lima VM "myapp" │     │ Lima VM "side"  │            │
 │  │                  │     │                 │            │
 │  │  /Users/john/    │     │  /Users/john/   │            │
 │  │   projects/myapp │     │   projects/     │            │
 │  │                  │     │   sideproject   │            │
 │  │  Docker daemon   │     │  Docker daemon  │            │
 │  │  Agent (claude)  │     │  Agent (codex)  │            │
 │  └──────────────────┘     └─────────────────┘            │
 │         ▲                        ▲                       │
 │         │                        │                       │
 │    Apple Virtualization.framework (VZ)                   │
 └──────────────────────────────────────────────────────────┘

Each sandbox is a lightweight Linux VM running on Apple's native Virtualization framework. It has:

  • Its own filesystem -- the VM sees only the project directory you mount, not your entire home directory.
  • Its own Docker daemon -- agents can build images and run containers without touching your host Docker or any other sandbox.
  • Live bidirectional file sharing -- VirtioFS mounts your host directory into the guest at the same absolute path. Edits on either side are instantly visible to the other. No sync process, no delay.
  • Full isolation -- a rogue rm -rf / inside the sandbox cannot touch your host (beyond the mounted directory).

Prerequisites

  • macOS 13 (Ventura) or later
  • Apple Silicon or Intel Mac
  • Homebrew installed
  • ~2 GB RAM per sandbox (configurable)
  • The docker CLI on your host (only if you want to talk to the in-VM Docker from your host -- optional)

Installation

brew install lima

Verify:

limactl --version
# lima version 2.1.1  (or similar)

Optionally install the Docker CLI (not Docker Desktop) if you want to run docker commands from your host against the sandbox's daemon:

brew install docker

That's it. Lima handles everything else -- downloading the Ubuntu image, configuring the VM, setting up SSH.


Core Concepts

Instances

A Lima instance is a named VM. Each instance has its own disk, CPU/memory allocation, mount configuration, and Docker daemon. Instances persist across reboots until you explicitly delete them.

limactl list              # list all instances
limactl list --format json # machine-readable output

Templates

A template is a YAML file that describes how to create an instance: what OS image to use, what to provision, what to mount. Lima ships with built-in templates (e.g. template:docker), and you can write your own.

Mounts

Mounts connect host directories to the guest. The key flags:

Flag Behavior
(default) Mounts ~ read-only, /tmp/lima writable
--mount-only <path>:w Mount ONLY <path>, writable. Nothing else.
--mount-only <path> Mount ONLY <path>, read-only.
--mount-none No host mounts at all. Fully isolated.

For agent sandboxing, you almost always want --mount-only <project>:w.

Mounts are not permanent. Unlike Docker sandboxes, a Lima VM is not tied to a single working directory. You can change, add, or remove mounts at any time using limactl edit (see Editing a sandbox).

Path preservation: Lima mounts host directories at the same absolute path inside the VM. If your project is at /Users/john/projects/foo on the host, it appears at /Users/john/projects/foo inside the VM -- not at /mnt/foo or some other translated path. This is the same behavior as Docker Sandboxes. It means error messages, stack traces, config files, and .git paths all reference paths that are valid on both sides. Agents don't get confused, and you can copy-paste paths between host and guest without translation.

VM Type

On macOS 13+, Lima defaults to vmType: vz (Apple Virtualization.framework). This gives you:

  • Native hypervisor performance (no QEMU overhead)
  • VirtioFS for fast file mounts
  • Rosetta 2 support for running x86_64 binaries in arm64 guests

You don't need to configure this -- it's the default.

Getting a Shell (How You Interact with Sandboxes)

You work inside a sandbox by getting a terminal session into the VM. Lima provides several ways to do this -- all of them are SSH under the hood, but Lima manages the keys, ports, and connection details so you never have to.

Interactive shell:

limactl shell myapp bash

This SSHs into the myapp VM and starts bash. You get a full Linux terminal. Your prompt changes (you'll see john@lima-myapp or similar), and everything you run is inside the VM. Ctrl-D or exit to leave.

Run a command directly (non-interactive):

limactl shell myapp uname -a
# Linux lima-myapp 6.x.x ...

Run an interactive agent directly:

limactl shell --workdir ~/projects/myapp myapp claude

This is the most convenient form for coding agents. It SSHs in, cds to your project directory, and launches the agent. The agent's TUI (terminal UI) works normally -- colors, key bindings, interactive prompts all pass through.

Set the working directory:

The --workdir flag tells Lima to cd into that path inside the VM before running the command. Since VirtioFS mounts your project at the same absolute path, --workdir ~/projects/myapp works identically to cd ~/projects/myapp on your host.

Raw SSH (if you ever need it):

Lima writes a per-instance SSH config file. Use it directly:

# SSH using Lima's config file
ssh -F ~/.lima/myapp/ssh.config lima-myapp

Note: limactl show-ssh exists but is deprecated. Use ssh -F ~/.lima/<name>/ssh.config lima-<name> instead.

You rarely need raw SSH. limactl shell <name> handles everything including terminal allocation, forwarding your env vars, and proper TTY setup so that interactive TUI programs (like Claude Code) work correctly.


Creating Your First Sandbox

The simplest way to get a sandboxed Docker environment for a project:

cd ~/projects/myapp

limactl start \
  --name myapp \
  --mount-only "$(pwd):w" \
  template:docker

This command:

  1. Creates a VM named myapp
  2. Downloads an Ubuntu LTS image (cached after first run)
  3. Installs Docker inside the VM
  4. Mounts ~/projects/myapp into the VM at the same absolute path, writable
  5. Does NOT mount your home directory or anything else

Wait for it to finish (first run downloads the image; subsequent starts are fast).

Verify it works

Shell into the VM:

limactl shell myapp bash

Inside the VM:

# Your project files are here, at the same path as on your host
ls ~/projects/myapp

# Docker is running inside the VM
docker run --rm hello-world

# Changes are bidirectional -- create a file
echo "hello from the sandbox" > ~/projects/myapp/sandbox-test.txt

Back on your host:

cat ~/projects/myapp/sandbox-test.txt
# hello from the sandbox

Clean up:

rm ~/projects/myapp/sandbox-test.txt

The Custom Template

The built-in template:docker works, but for agent sandboxing you'll want a custom template that pre-installs the tools your agents need.

The template is in this repo: agent-sandbox.yaml

Copy it to ~/.lima/ (or reference it by path when creating sandboxes):

cp agent-sandbox.yaml ~/.lima/agent-sandbox.yaml

What it provisions:

  • VZ + VirtioFS -- Apple Virtualization.framework with fast mounts
  • Docker (rootless) -- per-sandbox Docker daemon
  • Build tools -- build-essential, git, curl, wget, jq, ripgrep, fd-find
  • PostgreSQL 18 client -- psql, pg_dump, etc.
  • yq -- YAML/JSON/XML processor
  • Node.js 22 -- needed by most agents
  • No default mounts -- caller specifies --mount-only
  • Port forwarding disabled -- prevents collisions between sandboxes (Docker socket forwarding is kept)

Using the template

cd ~/projects/myapp

limactl start \
  --name myapp \
  --mount-only "$(pwd):w" \
  ~/.lima/agent-sandbox.yaml

First boot takes 2-3 minutes (provisioning). Subsequent limactl start myapp takes ~5 seconds.


Running Agents in Sandboxes

All agent interaction happens through a terminal session inside the VM. You run limactl shell <name> ... from your macOS terminal; Lima SSHs into the VM transparently and gives you a Linux shell (or launches the agent directly). Colors, key bindings, and interactive TUIs all work normally -- the experience is identical to running the agent on your host, except everything is sandboxed.

Installing agents (one-time setup per sandbox)

After creating a sandbox, install the agent(s) you want. These are installed inside the VM and persist across stop/start cycles (they only go away if you delete or factory-reset the sandbox).

# Shell into the sandbox
limactl shell myapp bash

# Inside the VM -- install whichever agents you use:
sudo npm install -g @anthropic-ai/claude-code    # Claude Code
sudo npm install -g @openai/codex                 # Codex
sudo npm install -g @google/gemini-cli            # Gemini CLI
sudo npm install -g opencode-ai                   # OpenCode

exit

You can also add these to the template's provision section so that every new sandbox comes with agents pre-installed (see The Custom Template).

Launching an agent

The most direct approach -- one command from your macOS terminal:

# Launch Claude Code, already cd'd to your project
limactl shell --workdir ~/projects/myapp myapp claude

This SSHs into the VM, changes to the project directory, and starts the agent. You're now interacting with Claude Code running inside the sandbox. When you quit the agent (/exit, Ctrl-C, etc.), you're back in your macOS terminal.

The same pattern works for any agent:

limactl shell --workdir ~/projects/myapp myapp codex
limactl shell --workdir ~/projects/myapp myapp gemini
limactl shell --workdir ~/projects/myapp myapp opencode

Interactive shell first, then agent

If you prefer to poke around or do setup before launching the agent:

# Get a bash shell inside the sandbox
limactl shell myapp bash

# You're now inside the VM (prompt shows lima-myapp)
cd ~/projects/myapp       # same absolute path as host
docker ps                 # sandbox's own Docker daemon
claude                    # launch agent

# When done with the agent, you're back in the VM's bash
# Ctrl-D or 'exit' to return to your macOS terminal

Authentication

OAuth-based agents (Claude Code, Gemini): Follow the interactive browser flow on first launch. The auth token is stored inside the VM and persists across stop/start.

API-key-based agents: Pass keys from your host without storing them in the VM:

# Forward a single key
limactl shell --workdir ~/projects/myapp myapp \
  env ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" claude

Or set keys permanently inside the sandbox:

limactl shell myapp bash
echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.bash_profile
echo 'export OPENAI_API_KEY="sk-..."' >> ~/.bash_profile
source ~/.bash_profile

Multiple terminals into the same sandbox

You can open as many terminal sessions into the same sandbox as you want. Each limactl shell myapp bash opens a new SSH connection to the same VM. This is useful when an agent is running in one terminal and you want to inspect something in another:

# Terminal 1: agent is running
limactl shell --workdir ~/projects/myapp myapp claude

# Terminal 2: check what containers the agent started
limactl shell myapp docker ps

# Terminal 3: watch resource usage
limactl shell myapp htop

Running Multiple Sandboxes Concurrently

This is where Lima really shines for your workflow. Each instance is fully independent -- its own VM, own Docker daemon, own disk.

Scenario: primary project + two side projects

# Terminal 1: main project
limactl start --name main \
  --mount-only "$HOME/projects/mainapp:w" \
  ~/.lima/agent-sandbox.yaml

limactl shell --workdir ~/projects/mainapp main claude

# Terminal 2: side project A (different checkout of same repo)
limactl start --name side-a \
  --mount-only "$HOME/projects/mainapp-feature-x:w" \
  ~/.lima/agent-sandbox.yaml

limactl shell --workdir ~/projects/mainapp-feature-x side-a claude

# Terminal 3: side project B
limactl start --name side-b \
  --mount-only "$HOME/projects/sideproject:w" \
  ~/.lima/agent-sandbox.yaml

limactl shell --workdir ~/projects/sideproject side-b codex

What's isolated, what's shared

Resource Isolated per sandbox? Notes
Filesystem (non-mount) Yes Each VM has its own root disk
Mounted project dir Shared with host That's the point -- bidirectional
Docker daemon Yes Each VM runs its own Docker
Docker images/containers Yes Built in one sandbox, invisible to others
Network Yes Each VM has its own network stack
Installed packages Yes apt-get install in one doesn't affect others
CPU/memory Yes Each VM has its own allocation

Listing all sandboxes

limactl list
# NAME      STATUS     SSH            VMTYPE    CPUS    MEMORY    DISK      DIR
# main      Running    127.0.0.1:...  vz        4       4GiB      50GiB     ~/.lima/main
# side-a    Running    127.0.0.1:...  vz        4       4GiB      50GiB     ~/.lima/side-a
# side-b    Running    127.0.0.1:...  vz        4       4GiB      50GiB     ~/.lima/side-b

Talking to a specific sandbox's Docker from the host

Each sandbox exposes its Docker socket at a unique path:

# Talk to main's Docker
export DOCKER_HOST=$(limactl list main --format 'unix://{{.Dir}}/sock/docker.sock')
docker ps

# Talk to side-a's Docker
export DOCKER_HOST=$(limactl list side-a --format 'unix://{{.Dir}}/sock/docker.sock')
docker ps

Or use Docker contexts:

docker context create main --docker "host=unix://$(limactl list main --format '{{.Dir}}')/sock/docker.sock"
docker context create side-a --docker "host=unix://$(limactl list side-a --format '{{.Dir}}')/sock/docker.sock"

docker --context main ps
docker --context side-a ps

Lifecycle Management

Starting and stopping

# Stop a sandbox (preserves all state)
limactl stop myapp

# Start it back up (fast -- no reprovisioning)
limactl start myapp

# Stop all running sandboxes
limactl list -q --filter '.status == "Running"' | xargs limactl stop

Deleting

# Delete a sandbox and its VM disk
limactl delete myapp

# Delete a stopped sandbox (fails if running)
limactl delete myapp

# Force delete a running sandbox
limactl delete --force myapp

Deleting a sandbox removes the VM disk, Docker images/containers inside it, and installed packages. Your host project directory is untouched.

Factory reset

If a sandbox gets into a bad state but you want to keep the name/config:

limactl factory-reset myapp
limactl start myapp

This re-provisions from scratch while keeping the same mount configuration.

Editing a sandbox

A sandbox is not permanently tied to one project directory. You can change mounts, CPU, memory, and other settings on a stopped instance using limactl edit:

limactl stop myapp

# Re-point at a different project directory
limactl edit myapp --mount-only ~/projects/other-project:w

# Or add a second mount alongside the existing one
limactl edit myapp --mount ~/projects/shared-libs:w

# Change resources
limactl edit myapp --cpus 8 --memory 8

# Remove all mounts (fully isolated VM)
limactl edit myapp --mount-none

limactl start myapp

You can also open the instance YAML directly in your editor for full control:

limactl stop myapp
limactl edit myapp    # opens the instance YAML in $EDITOR
limactl start myapp

This makes it easy to reuse a sandbox that already has your tools installed -- just swap the mounted directory instead of creating a new VM from scratch.

Checking what's inside

# SSH into a sandbox
limactl shell myapp bash

# Run a single command
limactl shell myapp docker ps
limactl shell myapp df -h
limactl shell myapp free -m

Snapshots

Snapshots let you checkpoint a sandbox's state and roll back. Useful before letting an agent make sweeping changes to installed packages or Docker state inside the VM.

Note: Snapshots capture the VM disk state, not your mounted host files (those are on your host filesystem -- use git for that).

# Create a snapshot before a big refactor
limactl snapshot create myapp --tag before-refactor

# List snapshots
limactl snapshot list myapp

# Agent made a mess of the VM's installed packages? Roll back.
limactl stop myapp
limactl snapshot apply myapp --tag before-refactor
limactl start myapp

# Delete a snapshot you no longer need
limactl snapshot delete myapp --tag before-refactor

Workflow: snapshot + git branch

# On host: create a git branch for the agent's work
cd ~/projects/myapp
git checkout -b agent/feature-x

# Snapshot the VM state
limactl snapshot create myapp --tag pre-agent

# Let the agent loose
limactl shell --workdir ~/projects/myapp myapp claude

# If you like the results: commit on host, delete snapshot
git add -A && git commit -m "agent: implement feature X"
limactl snapshot delete myapp --tag pre-agent

# If you don't: revert both
git checkout main && git branch -D agent/feature-x
limactl stop myapp
limactl snapshot apply myapp --tag pre-agent
limactl start myapp

Cloning Sandboxes

limactl clone creates a new VM from the current state of an existing one. The clone gets a full copy of the source's disk -- all installed packages, Docker images, agent configurations, and tool setups carry over. This is much faster than provisioning from scratch when you want multiple sandboxes with the same base environment.

# Clone an existing sandbox
limactl clone myapp myapp-copy

# Clone and re-point at a different project
limactl clone myapp feature-x --mount-only ~/projects/feature-x:w

# Clone, change resources, and start immediately
limactl clone myapp lightweight --cpus 2 --memory 2 --start

Use case: golden image pattern

Set up one sandbox with all your tools, then clone it for each new project instead of provisioning from scratch every time:

# One-time: create and configure a "golden" sandbox
limactl start --name golden \
  --mount-none \
  ~/.lima/agent-sandbox.yaml

# Install extra tools, configure shell, etc.
limactl shell golden bash
# ... install things, then exit

# For each new project: clone the golden image
limactl clone golden project-a \
  --mount-only ~/projects/project-a:w \
  --start
limactl clone golden project-b \
  --mount-only ~/projects/project-b:w \
  --start

Cloning from a specific snapshot

There's no flag to clone from a snapshot tag directly. Apply the snapshot first, then clone:

# Restore the source to a known-good state
limactl stop myapp
limactl snapshot apply myapp --tag clean-slate

# Clone from that state
limactl clone myapp new-sandbox --mount-only ~/projects/new:w

# Restore the source to its latest state
limactl snapshot apply myapp --tag latest
limactl start myapp

Shell Wrapper Script

The script is in this repo: sandbox.sh

Copy it somewhere on your $PATH:

cp sandbox.sh ~/bin/sandbox
chmod +x ~/bin/sandbox

It creates a sandbox if it doesn't exist, starts it if stopped, and forwards common API key env vars automatically. What it does:

  • Creates an instance using the template (if it doesn't exist)
  • Starts the instance (if stopped)
  • Forwards ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.
  • Shells in with --workdir set to the project directory

Usage:

# Create sandbox and drop into a shell
cd ~/projects/myapp
sandbox myapp

# Create sandbox and run claude directly
sandbox myapp claude

# Create sandbox for a specific directory
sandbox side-a ~/projects/other-checkout claude

# Override resources
SANDBOX_CPUS=2 SANDBOX_MEMORY=2 sandbox small-project

Editing Sandbox Files with Zed

Zed's remote development feature lets you edit files inside a Lima sandbox from your macOS host over SSH. Zed runs its UI locally on your Mac while language servers, terminals, and tasks run inside the VM. This means you get full LSP support -- completions, diagnostics, go-to-definition -- powered by the toolchains installed in the sandbox, not on your host.

Why bother (instead of just editing on the host)

With the default VirtioFS workflow you edit files on your host and agents run in the VM. That works, but your local editor uses its own LSP servers. Those may not match the VM's toolchain versions, may not see VM-only dependencies, or may not even be installed on your Mac at all.

With Zed remote development, the language servers, terminal, and task runner all run inside the sandbox. Zed's UI stays on your Mac -- fast and native -- while the heavy lifting happens in the VM.

Connecting Zed to a Lima sandbox

Lima manages SSH keys and ports for each VM and writes a per-instance SSH config file at ~/.lima/<name>/ssh.config. You can use this to connect Zed without manually looking up ports or keys.

Option A: Command line (quickest)

# Look up the SSH port from Lima's config
grep Port ~/.lima/myapp/ssh.config
# e.g. Port 52163

# Open the project in Zed (adjust user, port, and path)
zed ssh://john@127.0.0.1:52163/Users/john/projects/myapp

Option B: Include Lima's SSH config in your own (recommended)

Add an Include directive at the top of ~/.ssh/config (before any Host blocks) so that your SSH client -- and Zed -- can resolve Lima's host aliases automatically:

# ~/.ssh/config -- add this at the very top
Include ~/.lima/myapp/ssh.config

Now you can connect from the command line:

zed ssh://lima-myapp/Users/john/projects/myapp

Or from the Zed UI:

  1. Press Ctrl+Cmd+Shift+O (Open Remote Project)
  2. Click "Connect New Server"
  3. Enter lima-myapp as the host
  4. Choose your project path (e.g. /Users/john/projects/myapp)

Note: The SSH port can change when a sandbox is stopped and restarted. The Include approach handles this automatically because Lima updates its config file on each start. If you hard-code a zed ssh://...:<port>/... command, you'll need to look up the new port after a restart.

Option C: Zed settings file

Add the connection to your Zed settings (Cmd+,) so it persists across sessions. The -F flag tells SSH to read Lima's config file directly:

{
  "ssh_connections": [
    {
      "host": "lima-myapp",
      "args": ["-F", "~/.lima/myapp/ssh.config"],
      "projects": [{ "paths": ["/Users/john/projects/myapp"] }],
      "nickname": "myapp sandbox"
    }
  ]
}

First connection

The first time Zed connects, it downloads and installs a headless server binary inside the VM (stored in ~/.zed_server). This happens once per Zed version update. If the VM has restricted internet access, add "upload_binary_over_ssh": true to the connection settings and Zed will upload the binary from your Mac instead.

What runs where

Component Where it runs
Zed UI, rendering, keybindings Your Mac (local)
Language servers (LSP) Inside the Lima VM
Terminals opened in Zed Inside the Lima VM
Tasks (build, test, lint) Inside the Lima VM
File watching / indexing Inside the Lima VM
AI features (Agent, Inline Assist) Your Mac (talks to LLM providers)

Port forwarding for dev servers

If an agent or build script starts a dev server inside the sandbox and you want to access it from your Mac's browser, add port forwards in your Zed connection settings:

{
  "ssh_connections": [
    {
      "host": "lima-myapp",
      "args": ["-F", "~/.lima/myapp/ssh.config"],
      "projects": [{ "paths": ["/Users/john/projects/myapp"] }],
      "port_forwards": [{ "local_port": 3000, "remote_port": 3000 }]
    }
  ]
}

Tips

  • Extensions are synced. Zed extensions installed on your Mac are propagated to the remote server automatically. Language support, formatters, and linters work without extra setup.
  • Multiple sandboxes. Add one ssh_connections entry per sandbox. Give each a nickname to tell them apart in the Zed UI.
  • VirtioFS still works in parallel. The project directory is still mounted via VirtioFS, so you can open the same files directly on your Mac with any other editor at the same time. Edits from either side are visible instantly.
  • Agent + Zed side by side. A practical workflow: run an agent in one terminal (limactl shell --workdir ~/projects/myapp myapp claude) while editing the same project in Zed via remote development. The agent's file changes appear immediately in Zed, and Zed's LSP diagnostics update in real time.

Resource Tuning

Per-sandbox resources

Override CPU and memory at creation time:

limactl start --name myapp \
  --cpus 2 --memory 2 \
  --mount-only "$(pwd):w" \
  ~/.lima/agent-sandbox.yaml

Guidelines

Workload CPUs Memory Disk
Light agent (editing, no builds) 2 2 GiB 20 GiB
Agent with Docker builds 4 4 GiB 50 GiB
Agent with large test suites 4-8 8 GiB 50 GiB
Multiple concurrent sandboxes 2 each 2-4 GiB each 30 GiB each

macOS manages the total allocation across VMs. On a 16 GB Mac, you can comfortably run 2-3 sandboxes at 4 GiB each. On a 32 GB+ Mac, 4-6 is fine.

Checking resource usage

# From host: see all VMs
limactl list

# Inside a sandbox
limactl shell myapp free -m
limactl shell myapp df -h
limactl shell myapp docker system df

Reclaiming disk space

# Inside the sandbox: prune Docker
limactl shell myapp docker system prune -af

# From host: trim the VM disk
limactl shell myapp sudo fstrim -a

Networking Details

Default behavior

Each VM gets outbound internet access through the host. The guest can curl, apt-get install, docker pull, etc. No configuration needed.

Port forwarding is disabled in our template

By default, Lima auto-forwards every TCP/UDP port that a guest process listens on to the same port on the host. This is convenient for single-VM setups but causes collisions when running multiple sandboxes -- if three sandboxes each run postgres on port 5432, only one can bind the host's port 5432.

The custom template in this guide disables automatic port forwarding with this rule:

portForwards:
  # ... Docker socket forward (kept) ...
  # Block all automatic TCP/UDP port forwarding
  - guestIP: "127.0.0.1"
    guestPortRange: [1, 65535]
    hostIP: "127.0.0.1"
    proto: "any"
    ignore: true

Port forwarding rules are evaluated sequentially. Lima internally appends a catch-all "forward everything" rule at the end. By placing an ignore: true rule before it, we suppress all automatic forwarding. The Docker socket forward (which uses unix sockets, not TCP) is unaffected and still works.

What this means in practice:

  • Containers inside a sandbox talk to each other normally via localhost within the VM (e.g. your app container connects to localhost:5432 for postgres -- this all happens inside the VM's network namespace).
  • Nothing leaks onto the host's ports. No collisions between sandboxes.
  • The Docker socket is still forwarded, so docker CLI works from the host.

Selectively forwarding ports (when you need it)

If an agent runs a dev server and you need to access it from your host browser, add specific forwards before the ignore rule. Rules are matched top-down, so explicit forwards take priority:

portForwards:
  # Docker socket
  - guestSocket: "/run/user/{{.UID}}/docker.sock"
    hostSocket: "{{.Dir}}/sock/docker.sock"
  # Explicitly forward port 3000 for this sandbox
  - guestPort: 3000
    hostPort: 3000
  # Block everything else
  - guestIP: "127.0.0.1"
    guestPortRange: [1, 65535]
    hostIP: "127.0.0.1"
    proto: "any"
    ignore: true

To avoid editing the template for each sandbox, you can also use limactl edit to add port forwards to a specific instance after creation:

limactl stop myapp
limactl edit myapp    # opens the instance YAML in your editor
limactl start myapp

DNS

The guest can resolve host.docker.internal to reach services on your Mac (e.g. a local API server). This is configured in the template's hostResolver section.


Troubleshooting

"docker: command not found" inside the VM

The provisioning might still be running. Check:

limactl shell myapp tail -f /var/log/cloud-init-output.log

Or wait and retry. The probe in the template waits up to 30s for Docker.

Slow file operations on mounted directory

Make sure you're using VZ + VirtioFS (the default on macOS 13+). Verify:

limactl list myapp --format '{{.VMType}}'
# Should print: vz

If it shows qemu, delete and recreate with --vm-type vz.

"Instance already exists"

You tried to create a sandbox with a name that's already taken:

limactl list                    # see existing instances
limactl start myapp             # start existing one
limactl delete myapp            # or delete and recreate

VM won't start after macOS update

limactl factory-reset myapp
limactl start myapp

This re-provisions from scratch. Your mounted host files are unaffected.

Agent can't reach the internet

Check DNS inside the VM:

limactl shell myapp ping -c2 8.8.8.8
limactl shell myapp curl -I https://github.com

If DNS fails but ping works, check /etc/resolv.conf inside the VM.

Sandbox is using too much disk

# Check what's using space
limactl shell myapp df -h
limactl shell myapp docker system df

# Clean up
limactl shell myapp docker system prune -af
limactl shell myapp sudo apt-get clean
limactl shell myapp sudo fstrim -a

Garbled output or "unknown terminal type" (Ghostty, etc.)

If you use a terminal emulator like Ghostty, your host sets TERM=xterm-ghostty (or similar). SSH forwards this to the guest, but the Ubuntu VM doesn't have the matching terminfo entry, causing garbled output or warnings like tput: unknown terminal "xterm-ghostty".

Fix (one-time, per sandbox):

# Base64-encode the terminfo on the host, decode and install in the VM
infocmp -x xterm-ghostty 2>/dev/null | base64 | \
  xargs -I{} limactl shell myapp bash -c 'echo {} | base64 -d | tic -x -'

Note: piping directly into limactl shell doesn't work (limactl shell doesn't forward stdin to the remote command). The base64 approach embeds the data in the command string instead.

This persists across stop/start. The sandbox.sh wrapper script handles this automatically -- it detects missing terminfo and installs it before shelling in.

Quick workaround (no install needed): override TERM when shelling in:

TERM=xterm-256color limactl shell myapp bash

This isn't Ghostty-specific -- any terminal whose terminfo is missing from the guest has the same issue.

Mount not showing up / empty

Verify the mount path matches exactly. Lima mounts at the same absolute path:

# If your host directory is /Users/john/projects/myapp
# It appears in the VM at /Users/john/projects/myapp
limactl shell myapp ls /Users/john/projects/myapp

Reference

Essential commands

# Instance management
limactl start --name <n> --mount-only <path>:w <template>  # create
limactl start <name>                                         # start existing
limactl stop <name>                                          # stop
limactl delete <name>                                        # delete
limactl delete --force <name>                                # force delete
limactl list                                                 # list all
limactl factory-reset <name>                                 # reprovision

# Shell access
limactl shell <name> bash                              # interactive shell
limactl shell --workdir <dir> <name> <cmd>             # run command
limactl shell <name> env KEY=VAL <cmd>                 # with env vars

# Editing and cloning
limactl edit <name> --mount-only <path>:w              # change mounts
limactl edit <name> --cpus <n> --memory <n>            # change resources
limactl clone <old> <new>                              # clone a sandbox
limactl clone <old> <new> --mount-only <path>:w        # clone + new mount

# Snapshots
limactl snapshot create <name> --tag <tag>
limactl snapshot list <name>
limactl snapshot apply <name> --tag <tag>              # (must stop first)
limactl snapshot delete <name> --tag <tag>

# Docker (from host, talking to sandbox's Docker)
export DOCKER_HOST=$(limactl list <name> --format 'unix://{{.Dir}}/sock/docker.sock')
docker ps

Lima docs

Underlying technology

  • Apple Virtualization.framework: the hypervisor
  • VirtioFS: the filesystem sharing mechanism (bidirectional, zero-copy)
  • vfkit / Code-Hex/vz: the Go bindings Lima uses under the hood
  • Ubuntu LTS: the default guest OS

About

A guide for using Lima VMs for Agent Sandboxes

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages