A clone of exe.dev, the ssh-based microVM service, that runs entirely on your own Mac. Real Linux microVMs booted from OCI images in about a second, managed over ssh, with persistent disks and an HTTP front door.
$ ssh box@shed
shed: creating vm box...
dev@box:~$
shed keeps a pile of small Linux machines behind your Mac. The idea comes from exe.dev: creating a computer should be about as cheap as creating a file. shed does the same thing but entirely on your own hardware, so there is no account and nothing metered.
Each machine is a real VM on Apple's hypervisor, with its own kernel,
init, and disk — not a container. That's the point: it's where you put
work you don't want running loose on your Mac. A coding agent with root, a
curl | sh you don't quite trust, a service that wants port 80. Whatever
happens inside stays inside; the VM can't see your Mac's disk or
processes.
VMs are cheap enough that you don't have to be tidy. A stopped VM costs
nothing but its disk, and ssh boots it again in about a second, so it's
fine to have ten half-finished experiments sitting in ls. Everything is
addressed by name: ssh box@shed opens a shell (creating the VM first if
it doesn't exist), ssh shed cp box box2
clones the whole machine in a couple of seconds, and
http://box.shed.localhost:8080 reaches whatever box is serving. There is
no GUI and no YAML; the interface is ssh.
The default image is sheduntu: Ubuntu 26.04 with the
usual tools installed (git, curl, vim, tmux, htop, ripgrep, jq), the
GitHub CLI (gh), a dev user with passwordless sudo, plus mise and uv
in /usr/local/bin, node 24 via mise, and python 3.14 (uv-managed) as
dev's default next to the apt python3. Claude Code is preinstalled for
dev, so claude works on first login — a VM is the natural place to
let an agent work with less oversight than you'd give it on your Mac.
You land in zsh with a starship prompt, history
suggestions and syntax highlighting as you type, ctrl-r/ctrl-t fuzzy
search through history and files, and the small tools everyone installs
anyway: fzf, bat, fd, zoxide (z <dir> jumps to where you
usually work), tree. There is no framework behind it — cat ~/.zshrc
is about forty lines and explains itself, and deleting it is a supported
opinion. The prompt sticks to plain unicode so it renders in any
terminal; if yours has a Nerd Font, one command upgrades it:
starship preset nerd-font-symbols -o ~/.config/starship.tomlThe image is baked locally the first time you use it: a throwaway VM
boots upstream ubuntu:26.04, runs the recipe, and its rootfs becomes
the cached base image. Takes about a minute, rebakes whenever the recipe
changes (bump sheduntuVersion to pick up upstream Ubuntu updates), and
old bakes are pruned once no VM uses them. A VM keeps booting the bake it
was created on; a new bake only affects VMs created after it. Any other
OCI image works via --image.
One daemon (shedd) runs three things:
- SSH gateway (127.0.0.1:2222). Routing is by username, sshpiper-style:
user
shedis the control plane (ssh shed ls), any other username names a VM and the session is brokered into that VM's sshd (ssh box@shed). Key-only auth against~/.local/share/shed/authorized_keys. The same gateway also listens on a local unix socket with no client auth (the 0600 socket is the auth) — that's whatbin/shedtalks to. - VM manager over Apple's Virtualization.framework (Code-Hex/vz).
An OCI image is pulled with go-containerregistry, flattened, and streamed
through Microsoft's pure-Go
tar2ext4into a read-only ext4 base disk — the rootfs never touches the host filesystem, so no root is needed and ownership/setuid/device nodes survive. Each VM adds a sparse writable ext4 data disk (mke2fs), joined by overlayfs at boot. New VMs get 2 vCPUs, 4 GB of RAM and a 10 GB disk unlessnewsays otherwise. The kernel is shed's own build of Linux (6.18 with the Kata Containers configuration, the one Apple'scontainerdirect-boots), published as a release asset on this repo'skernel-<version>tags, fetched once and cached.kernel/README.mdhas the recipe. - HTTP front door (127.0.0.1:8080).
http://<vm>.shed.localhost:8080proxies to the VM — the smallestEXPOSEd port, orshare port. VMs are private by default;ssh shed share <vm>prints a signed link,share set-publicopens it up.
Inside every VM, a small static Go agent (shedguest) rides the initramfs
as pid 1: it assembles the overlay root, DHCPs on the NAT network, installs
your keys, serves ssh (its own embedded sshd — so even distroless images
are ssh-able), supervises the image's ENTRYPOINT/CMD, and talks to the
daemon over vsock. When macOS's Local Network privacy blocks TCP to the
guest, everything transparently falls back to vsock.
Sessions land as a proper login: the default user dev (uid 1000,
passwordless sudo — baked into sheduntu) when the image has it, root
otherwise, running the user's shell from /etc/passwd as a login shell in
$HOME, with /etc/motd shown on interactive connects. scp/sftp run as
the same user, so ownership comes out right. default_user in config.toml
changes the preference; sudo -i is always one step from root.
VM state machine: creating → stopped → starting → running → stopping,
plus error. Stopped VMs keep their disk and release CPU/RAM back to the
pool (ls -l shows usage). ssh to a stopped VM boots it on demand (~1 s).
If the daemon dies, records reconcile to stopped on restart.
- Apple Silicon Mac, macOS 15+
- Go 1.25+, Homebrew (
brew install e2fsprogs) - A 16 MB one-time kernel download (from this repo's releases) on first
shedd serve, and a minute to bake sheduntu on first use
make build # builds shedd + guest agent, codesigns (required for vz)
bin/shedd install # ssh config (Host shed) + authorized_keys from ~/.ssh
bin/shedd # run the daemon in the foreground (same as shedd serve)
bin/shedd doctor # if something is off
ssh shed new [name] [--image ref] [--cpu N] [--memory MB] [--disk GB] [--no-start]
# default image: sheduntu
ssh shed ls [-l] [--json]
ssh shed start|stop|restart <vm>...
ssh shed rm <vm>...
ssh shed cp <src> <dst> # instant clone (APFS copy-on-write)
ssh shed rename <old> <new>
ssh shed share <vm> # signed URL for a private vm
ssh shed share set-public|set-private <vm>
ssh shed share port <vm> <port>
ssh shed ssh-key ls|add # add - reads the key from stdin
ssh shed whoami | doc | browser <vm>
ssh shed version [--json] # daemon version
Locally, bin/shed runs the same commands without ssh: it talks to the
daemon over a unix socket (~/.local/share/shed/control.sock, 0600 — the
file mode is the auth), so no keys or agent are involved. shed ls,
shed new mybox, cat key.pub | shed ssh-key add -. The client sends
its argv line to the daemon verbatim; the command surface is one cobra
tree either way. Interactive shells still go over ssh.
scp/sftp and ssh -L work through the gateway:
scp file.txt box@shed:/root/
ssh -L 8080:localhost:80 web@shed
make test # unit tests (no VMs, no signing needed)
make build # rebuild + codesign
Versions are git tags (v0.1.0, semver, v0.x while things still move).
make build bakes git describe --tags --dirty into both binaries, so a
dev build reports something like v0.1.0-3-g19ad079-dirty, or dev plus
the commit before the first tag. bin/shed version prints the client and
daemon versions and warns when they differ, which is what a stale daemon
looks like after a rebuild. bin/shedd --version works too. To release:
git tag v0.1.0 && git push origin v0.1.0.
State lives in ~/.local/share/shed/ (VM records, disks, keys, optional
config.toml), caches in ~/Library/Caches/shed/ (kernel, base disks by
image digest). Serial console of each VM:
~/.local/share/shed/vms/<name>/serial.log.
docs/design/ describes each subsystem (ssh gateway, control plane, VM
lifecycle, images and storage, guest agent, networking, HTTP front door,
configuration) in more depth than this README. CLAUDE.md holds the
operational notes for working on shed with a coding agent.
- VMs die with the daemon (Virtualization.framework VMs live in-process);
records reconcile to
stoppedon restart. *.shed.localhostresolves to loopback in Chrome, Firefox and curl, which handle.localhostthemselves. Safari asks the macOS resolver, which only knows plainlocalhost, so it can't find the host. Add a line per VM to/etc/hosts(127.0.0.1 box.shed.localhost), or point/etc/resolver/shed.localhostat a local dnsmasq withaddress=/shed.localhost/127.0.0.1for a wildcard.- Images run under the agent as pid 1 — systemd in the image is not
executed (ubuntu works fine;
systemctldoes not). - No TLS on the front door, no
ssh -R, no ssh-agent forwarding yet.