Isolated Lima dev VM for macOS: own IP via vzNAT, no mounts unless configured, no port forwards, rootless Docker, zsh + oh-my-zsh, mise, neovim, Rust, GitHub SSH access.
go run . create [name] # create and start the VM
go run . start [name] # boot a stopped VM
go run . stop [name] # shut it down, keeping the disk
go run . delete [name] # delete it (confirms first; -force skips)
go run . list # list VMs with status, size, IP and SSH hostname
go run . status [name] # one VM in detail, including its IP
go run . status [name] -ip # just the IP, for scripts
go run . completion zsh # print the shell completion script
go run . version # print the build version
limactl shell <name> # open a shell insideSee CLAUDE.md for how Lima provisioning works in detail.
- macOS with Lima 2.0.0+ (
limactl). - GitHub CLI (
gh), logged in with theadmin:public_keyscope:gh auth refresh -h github.com -s admin:public_key. - Go 1.27.1+ (the
goline ingo.mod), to run the CLI withgo run .(not needed for a released binary).
The formula lives in the
robsonpeixoto/homebrew-tap
tap (Formula/dev-vm.rb there):
brew tap robsonpeixoto/tap
brew install robsonpeixoto/tap/dev-vmLater: brew update && brew upgrade dev-vm.
The tap is public, but this repo's release assets are private, so the formula
fetches them through the GitHub API with a custom download strategy.
Credentials come from HOMEBREW_GITHUB_API_TOKEN, the gh CLI login, or the
macOS keychain, in that order — a logged-in gh is enough.
Prebuilt binaries are attached to every release. The repo is
private, so downloads need an authenticated gh:
gh release download v0.10.0 -p 'dev-vm_*_darwin_arm64.tar.gz'
tar xzf dev-vm_0.10.0_darwin_arm64.tar.gz
install dev-vm_0.10.0_darwin_arm64/dev-vm /usr/local/bin/dev-vmThe binaries are unsigned, so macOS quarantines them on first run — clear it
with xattr -d com.apple.quarantine /usr/local/bin/dev-vm.
linux/{amd64,arm64} builds are published too, but the tool drives Lima with
vz/vzNAT (Apple Virtualization.framework) and only works on macOS.
Everything the VM needs is embedded in the binary; there are no data files to
ship alongside it. Verify a download against checksums.txt from the same
release.
-
Create the VM (name defaults to
default):go run . create myvmThis generates an SSH key, registers it on GitHub, boots the VM (2 vCPUs, 2 GiB RAM, 50 GiB disk) and runs provisioning.
-
Optional — bring your dotfiles (bare repo checked out over
$HOME):go run . create myvm -dotfiles git@github.com:user/dotfiles.gitPut
"dotfiles": "<repo>"in thedefaultblock of~/.config/dev-vm/settings.json(see step 5) to enable it for every VM;-no-dotfilesskips it. -
Optional — size the VM.
-memoryand-diskare plain integers in GiB:go run . create myvm -cpus 8 -memory 16 -disk 100The same keys work in
~/.config/dev-vm/settings.json(see step 5) as machine-wide defaults. A flag beats settings.json, which beats the built-in defaults. Size is baked into the instance at create time, so changing it meansgo run . delete myvm && go run . create myvm -cpus …. -
Optional — nested virtualization, for running VMs inside the dev VM:
go run . create myvm -nestedNeeds an Apple M3 or later, and like the size it is fixed at create time. See nested virtualization.
-
Optional — settings.json.
~/.config/dev-vm/settings.jsonholds adefaultblock that applies to every VM, plus avmsblock keyed by VM name that overrides it key by key.clonelists repositories to clone in the guest, per GitHub org, with the directory they go under,mkcertcopies the host mkcert root CA into the VM,ghosttyinstalls the xterm-ghostty terminfo entry in it,nestedgives the guest/dev/kvmandmountsshares host directories into it:{ "default": { "cpus": 8, "memory": 16, "disk": 100, "dotfiles": "git@github.com:user/dotfiles.git", "mkcert": true, "ghostty": true, "nested": false, "clone": [ { "org": "robsonpeixoto", "basedir": "${HOME}/Code/robsonpeixoto", "repositories": ["dev-vm", "echo-server"] } ], "mounts": [ {"source": "~/Downloads", "destination": "~/Downloads", "mode": "ro"} ] }, "vms": { "new-vm": { "cpus": 4, "memory": 4, "clone": [], "mkcert": false, "ghostty": false, "mounts": [] } } }Here
new-vmgets 4 vCPUs and 4 GiB, keeps the default 100 GiB disk and dotfiles, clones nothing, mounts nothing and gets neither the CA nor the terminfo entry; every other VM gets thedefaultblock as written. An override replaces the key outright rather than merging into it, so"clone": []means no repositories and"dotfiles": ""means no dotfiles. Unknown keys are rejected, at either level.basediris a guest path;${HOME}(or$HOME) in it expands in the guest. Each repository is cloned over SSH asgit@github.com:<org>/<repo>.gitinto<basedir>/<repo>, as the last user provisioning step, so the provisioned key andknown_hostsare already in place. A repository whose directory already exists is skipped, and one that fails to clone (no access, wrong name) is logged and skipped as well — neither stops the others. Boot provisioning reruns the step on every start, so it also picks up repositories added to the setting later."mkcert": trueneeds mkcert on the host with a CA already generated (mkcert -install);createfails with a hint otherwise. See mkcert root CA."ghostty": trueneeds Homebrew's ncurses on the host (brew install ncurses). See ghostty terminfo.mountsentries take a hostsource, a guestdestinationand amode. See host mounts."nested": trueneeds an Apple M3 or later. See nested virtualization. -
Get in:
limactl shell myvm # or, with the hostname from the SSH column of `go run . list`: ssh -F ~/.lima/myvm/ssh.config lima-myvm
-
Check what exists — name, Lima status, size, guest IP, SSH hostname:
go run . listNAME STATUS CPUS MEM DISK IP SSH myvm Running 4 8GiB 50GiB 192.168.64.26 lima-myvmIPis read from the guest, the only place it exists, so a VM that is not running shows-. Reach anything the VM serves at that IP — there are no port forwards:curl http://192.168.64.26:3000
-
One VM in detail — the same fields plus the dotfiles repo, key path and creation time. The name defaults to
default:go run . status myvm-ipprints the guest IP alone, for scripts:curl "http://$(go run . status myvm -ip):3000"A VM that is not running, or that does not answer within the 5 s deadline, prints an empty line and exits 0.
-
Stop and start it. A host reboot leaves every VM stopped;
startboots it again, re-running provisioning and the readiness probes:go run . stop myvm go run . start myvm
stop -forcekills the VM instead of shutting the guest down gracefully — faster, but unwritten guest data is lost. -
Throw it away (deletes the VM, the GitHub key and the local key pair). It asks first — type the VM name back to go ahead, anything else aborts:
go run . delete myvmScripts and CI pass -force to skip the prompt. Without a terminal on
stdin, delete refuses instead of hanging, so -force is mandatory there:
go run . delete myvm -forceThe token scope is checked before the prompt, and the VM disk is deleted
last — after the GitHub key and the local pair — so a gh failure
(logged out, expired token, offline) leaves the VM usable.
State lives in ~/.config/dev-vm/state.json, keys in
~/.config/dev-vm/keys/.
Every VM has its own IP on the vzNAT interface, and that IP is the only way
into it. The rules behind that:
-
No port forwarding, ever.
portForwardsinlima/dev-vm.yamlignores both127.0.0.1and0.0.0.0. A service in the guest is reached at the guest IP and its own port, never at a forwarded port on the Mac. -
No firewall in the guest.
lima/scripts/firewall-system.shruns first on every boot: it deletes theinet/ip/ip6 filtertables, then disables, stops and masksufw.serviceso a package upgrade or a dotfiles checkout cannot switch one back on. Lima's owntable ip natis left alone — itsLIMADNSchains redirect guest DNS, and a blanketnft flush rulesetwould break name resolution. -
Every port is usable, including below 1024. The guest sets
net.ipv4.ip_unprivileged_port_start=0(/etc/sysctl.d/99-dev-vm.conf), so rootless Docker can publish 80 and 443:limactl shell myvm docker run -d -p 80:80 nginx curl "http://$(go run . status myvm -ip)/" -
The IP comes from the guest. Lima does not report it —
limactl list --format jsoncarries only the MAC, and the macOS DHCP lease file keeps stale rows — solistandstatusrunlimactl shell <name> ip -4 -json addr show lima0, once per running VM, in parallel, with a 5 s deadline. A VM that is stopped or slow to answer shows-rather than failing the command. -
From inside the guest,
my-ipprints the same address (/usr/local/bin/my-ip): it readslima0, falling back to the interface carrying the default route. -
It is a DHCP lease, so it can change when the VM reboots. Read it from
go run . listorgo run . status <name>instead of hardcoding it.
limactl shell and the generated ~/.lima/<name>/ssh.config still go through
Lima's own ssh tunnel on 127.0.0.1. That is internal to Lima, not a
portForwards entry.
Rootless Docker publishes ports through RootlessKit, and the pair of drivers it
uses decides which publish forms work. This VM runs the slirp4netns network
driver with the builtin port driver, the only combination where all of these
work:
docker run -d -p 8080:80 nginx # every interface
docker run -d -p 80:80 nginx # below 1024
docker run -d -p "$(my-ip)":8080:80 nginx # the guest's own vzNAT IPThe last one is the reason it matters. pasta is the faster network driver,
but RootlessKit accepts only the implicit, none or pesto port drivers
alongside it, and under implicit the ports are bound inside a namespace whose
sole address is pasta's own 10.0.2.100, so publishing on the guest IP fails:
failed to bind host port 192.168.64.12:8081/tcp: cannot assign requested address
pesto lifts that limit but needs passt >= 2026_05_07, newer than Ubuntu
26.04 ships. builtin binds in the guest's own network namespace instead, at
the cost of a userspace copy per connection.
Nothing configures this. dockerd-rootless.sh picks exactly that pair when
slirp4netns is on PATH, so the whole setup is one package in
docker-system.sh's list — there is no systemd drop-in and no
DOCKERD_ROOTLESS_ROOTLESSKIT_* variable anywhere in this repo. The package is
separate on purpose: docker-ce-rootless-extras ships rootlesskit but not
that binary, and without it the daemon falls back to pasta and the failure
above.
Reaching a published port from the Mac always means the guest IP —
curl "http://$(go run . status myvm -ip):8080/". localhost:8080 on the Mac
never answers; there are no port forwards.
dev-vm completion <bash|zsh|fish> prints the completion script for that
shell. It completes subcommands, the create, stop and delete flags, and
the recorded VM names for start, stop and delete.
Try it in the current shell:
source <(dev-vm completion bash)
source <(dev-vm completion zsh)
dev-vm completion fish | sourceHomebrew installs the three scripts for you, so brew install dev-vm needs no
extra step. Otherwise install one permanently:
dev-vm completion bash > /usr/local/etc/bash_completion.d/dev-vm
dev-vm completion zsh > "${fpath[1]}/_dev-vm"
dev-vm completion fish > ~/.config/fish/completions/dev-vm.fishThe release tarballs also carry the scripts in completions/.
The zsh script needs compinit to have run first. Names come from the hidden
dev-vm __names command, which prints the VMs in the state file.
.github/workflows/ci.yml runs make check-format-go, go vet, go build and
go test on every push to main and every pull request, plus a parallel
shell job over every shell file in the repo: shellcheck on
lima/scripts/*.sh, lima/files/*.sh, lima/files/install-neovim and
completions/dev-vm.bash
(zsh and fish are dialects shellcheck does not support), then
make check-format-shell.
Code style lives in the Makefile, so CI and a local run cannot drift:
make format # gofmt -w, shfmt -w
make check-format # the same, diff-only; what CI runsformat-go/format-shell and check-format-go/check-format-shell are the
halves, one per CI job. SHELL_DIRS names the directories shfmt walks (lima
and completions); it finds shell files there by extension or shebang, so a new
script is covered without touching the Makefile, and takes its 4-space indent
from the [[shell]] section of .editorconfig at the repo root.
Pushing a v* tag runs .github/workflows/release.yml, which cross-compiles
linux/{amd64,arm64} and darwin/{amd64,arm64}, injects the tag into
main.version with -ldflags -X, and publishes the four tarballs (each with
README.md and completions/) plus checksums.txt on a GitHub Release with
generated notes:
git tag -a v0.10.0 -m v0.10.0
git push origin v0.10.0A last job checks out
robsonpeixoto/homebrew-tap,
bumps version and the four sha256 values in its Formula/dev-vm.rb from
checksums.txt, and pushes, so the tap serves the new release without manual
editing. Two requirements:
- The repository secret
HOMEBREW_TAP_TOKENmust hold a PAT withcontents: writeon the tap repo —github.tokenonly reaches this repository. - Each
sha256line must stay two lines below itsurlline in the formula; the job fails loudly otherwise.
create generates a fresh ed25519 key pair on every run — never a key from
~/.ssh — and registers the public key on GitHub with the title
dev-vm/<host>/<name>, replacing any previous key with the same title.
-create-ssh-key=false reuses the pair from an earlier create.
<host> is the short host name from os.Hostname() (no .local suffix), so
two machines that both create default register two distinct keys instead of
deleting each other's. The exact title is recorded as github_key_title in the
state file; delete deletes by that stored title, falling back to the bare VM
name for VMs created before titles were qualified.
- Host:
~/.config/dev-vm/keys/<name>(dir 0700, private key 0600, enforced on every create). The host's~/.sshis never read; Lima does not load~/.ssh/*.pubinto the guest. - Guest: the private key is uploaded to
~/.ssh/id_ed25519, and the stanza pinning it for github.com withIdentitiesOnly yesis installed as~/.ssh/config.d/10-github.conf. ~/.ssh/configis never written by this repo. Loading the drop-ins is the dotfiles' job: the repo's~/.ssh/configmust carryInclude ~/.ssh/config.d/*.conf, near the top, since ssh keeps the first value it sees for a keyword. Without that line the drop-in is inert — git over SSH still works, because ssh tries~/.ssh/id_ed25519by default, butIdentitiesOnly yesis not in effect, so a dotfiles-supplied agent or extra key can be offered to GitHub first.deletedeletes the GitHub key, the local pair, the VM and the state entry, in that order, after confirming the VM name (-forceskips the prompt). The irreversible step is last, and the token scope is checked first, so aghfailure aborts with the VM still there.
With "mkcert": true in the settings (see step 5 of
usage), create reads mkcert -CAROOT on the host and gives the VM
the same certificate authority, so a certificate issued in the guest is
trusted by the host browser and vice versa.
rootCA.pemandrootCA-key.pemare copied into the guest CAROOT, which is~/.local/share/mkcertunlessCAROOTorXDG_DATA_HOMEsays otherwise. The key comes along on purpose: without it the guest can only trust the CA, not issue certificates from it (mkcert app.test).rootCA.pemalso goes into the template'scaCerts.files, which hands it to cloud-init: it lands in/usr/local/share/ca-certificatesandupdate-ca-certificatestrusts it system-wide, socurland every library using the system store accept guest-issued certificates with no extra flag.- The
caCerts.filesentry stored in~/.lima/<name>/lima.yamlis the host path, and Lima re-reads it on every start (it regeneratescidata.isofromlima.yamleach time). Deleting or moving the host CAROOT therefore breaksgo run . start <name>for that VM until the file is back or the entry is removed withlimactl edit <name>. - Nothing runs
mkcert -installin the guest. The system store is covered bycaCertsabove; trusting the CA in a guest browser's own NSS store is a manualmkcert -install(libnss3-toolsis already installed). - mkcert itself is not installed by this repo — it comes from mise or the dotfiles. The CA files are copied either way.
- The setting is read at create time like everything else, so turning it on later means delete and create again.
rust-user.sh installs rustup as the guest login user,
with --profile minimal and the stable toolchain, so cargo and rustc are
there for the neovim plugins that ship a Rust component — chiefly
blink.cmp, whose
require('blink.cmp').build() runs cargo build --release on the plugin's
fuzzy matcher.
- Per-user, not an apt package: rustup owns
~/.rustupand~/.cargo, which keepsrustup update,rustup component addandcargo installworking without sudo.cargofrom the Ubuntu archive is deliberately not installed — crates raise their minimum Rust version far faster than an LTS archive moves, and two toolchains on onePATHis worse than none. PATHcomes from/etc/profile.d/rust.sh, so rustup runs with--no-modify-pathand appends nothing to~/.zshrcor~/.profile, which the dotfiles own.zsh-system.shwires that file into/etc/zsh/zshenvtoo, so non-login shells — and any nvim started from one — findcargo.- The rest of what a
cargo buildneeds is already there:git(blink.cmp'sbuild.rsrunsgit rev-parse HEAD) fromgit-system.shand the linker frombuild-essentialinneovim-system.sh. blink.cmp also wants Neovim 0.12+, which the release tarball provides; see provisioning steps. --profile minimalmeans noclippy,rustfmtorrust-docs. Add what you want withrustup component add clippy rustfmt.- Boot provisioning never upgrades an existing toolchain — the script skips
when
~/.cargo/bin/cargoexists. Upgrading isrustup update.
With "ghostty": true in the settings, create dumps the xterm-ghostty
terminfo entry on the host and the guest compiles it, so the TERM that
Ghostty exports keeps working inside the VM instead of
leaving ncurses programs on the xterm-256color fallback (or complaining that
the terminal is unknown).
- The dump uses Homebrew's
infocmp($(brew --prefix ncurses)/bin/infocmp -x xterm-ghostty). The macOS built-in is ncurses 6.0 from 2015 and mangles the extended (-x) capabilities the entry mostly consists of, sobrew install ncursesis a requirement rather than a preference;createfails with that hint when the binary is not there. - The entry itself comes from wherever
TERMINFOpoints, which is the app bundle whendevvmruns inside Ghostty. Outside it,createretries withTERMINFO=/Applications/Ghostty.app/Contents/Resources/terminfo. - The dump is staged base64-encoded and
ghostty-terminfo-system.shpipes it throughtic -x -o /usr/share/terminfo, as root: system-wide, sosudoand root shells get the entry too. base64 is not decoration — see CLAUDE.md on Lima templatingmode: datacontent. - Nothing sets
TERMin the guest: ssh andlimactl shellcarry the host value, which is the whole point of compiling the entry there. - Like every other setting, this is read at create time, so turning it on later means delete and create again.
Each entry of the mounts setting (see step 5 of usage) shares one
host directory into the guest through Lima's own mounts (virtiofs under vz):
| Key | Required | Meaning |
|---|---|---|
source |
yes | Host directory: an absolute path, ~ or ~/... (the host home) |
destination |
no | Guest path: absolute, ~ or ~/... (the guest home, /home/<user>.linux). Defaults to source as expanded on the host, e.g. /Users/<user>/Code |
mode |
no | "ro" (default) or "rw" |
{"default": {"mounts": [
{"source": "~/Code", "destination": "~/host-code", "mode": "rw"},
{"source": "/Volumes/data"}
]}}createrejects a relative path,~user, an unknownmode, and paths holding quotes, backslashes, braces or control characters; Lima itself rejects system destinations such as/etc,/usror/home.- Like the size, mounts are fixed at create time: the list lands in the
instance's
lima.yaml. Change them withlimactl edit <name>or delete + create. - A
"rw"mount lets anything in the VM write to the host directory. That includes every container you run with access to it, so the VM stops being an isolation boundary for that path — keep mounts narrow and read-only unless writing is the point.
go run . create myvm -nested (or "nested": true in the settings) sets
Lima's nestedVirtualization on the instance, so the guest can run VMs of its
own — a QEMU/KVM guest, or a Linux Lima instance for working on this repo from
inside the dev VM.
go run . create myvm -nested
limactl shell myvm -- ls -l /dev/kvm
# crw-rw---- 1 root kvm 10, 232 /dev/kvm- Apple M3 or later, with
vmType: vz.createreadsmachdep.cpu.brand_stringand refuses earlier silicon (and Intel) before it registers a GitHub key, rather than letting the VM fail to start. - The flag wins over the setting in both directions:
-nestedturns it on for a VM the settings leave off,-nested=falseturns it off for one thedefaultblock turns on. - Fixed at create time, like the size: the template is flattened into
~/.lima/<name>/lima.yamlwhen the instance is created. Changing it meanslimactl edit <name>or delete and create again. - Without it the guest kernel sees no virtualization extensions and creates no
/dev/kvmat all, which is whykvm-system.shis a no-op there. /dev/kvmisroot:kvm0660, and the image does not put the login user in that group;kvm-system.shdoes, on every boot. Group membership applies to the next login, which the nextlimactl shellalready is.- Nothing installs QEMU, libvirt or Lima in the guest — that is the operator's
apt-get install qemu-system-arm, in keeping with upgrading the guest. Lima's own note applies: a nested QEMU needs-cpu host, e.g.qemu-system-aarch64 -accel kvm -cpu host -M virt.
The guest is pinned to Ubuntu 26.04 LTS, by
base: [template:_images/ubuntu-26.04] in lima/dev-vm.yaml.
The pin is the point. template:_images/ubuntu-lts is a symlink inside the
installed Lima release, so it floats with it: upgrading Lima can move new VMs
to the next LTS with nothing in this repo changing, and two identical create
runs on different days can produce different guests. The per-release names are
stable — Lima keeps one for every LTS it has shipped (ubuntu-20.04,
ubuntu-22.04, ubuntu-24.04, ubuntu-26.04).
A bump never touches existing VMs. The template is flattened at create time, so the release is fixed for an instance's life; only VMs created afterwards get the new one.
Ubuntu ships an LTS every two years and Lima adds the template around release
day. Bump once the template is in the Lima version you run and Docker's apt
repo carries the new codename — docker-system.sh derives it from the guest's
/etc/os-release, and Docker publishes a suite per codename, so a too-early
bump fails provisioning at apt-get update. (mise's repo is codename-agnostic.)
-
Confirm the template exists and see what
ubuntu-ltscurrently points at:ls "$(brew --prefix lima)/share/lima/templates/_images" | grep ubuntu grep -m1 location "$(brew --prefix lima)/share/lima/templates/_images/ubuntu-lts.yaml"
-
Point the
base:entry inlima/dev-vm.yamlat the newtemplate:_images/ubuntu-XX.YY, and update the release named in this section and in CLAUDE.md. -
Create a throwaway VM, walk the checklist, then delete it:
go run . create pinbump go run . delete pinbump
Every new LTS has to pass all three on that fresh create:
- Boot —
createfinishes without a provisioning error, andlimactl shell pinbump sudo cat /var/log/cloud-init-output.logshows no failed script. - Docker probe — the rootless-Docker readiness probe passes; confirm with
limactl shell pinbump docker info. - GitHub probe — the SSH probe passes; confirm with
limactl shell pinbump ssh -T git@github.comreportingsuccessfully authenticated.
Nothing upgrades itself. The VM installs what it needs on the first boot
and then holds still: no cron, no unattended-upgrades, no apt-daily timers.
A workspace that changes versions on its own is a workspace whose breakage is
impossible to date, and a patch landing next to boot provisioning is a dpkg
lock race.
Two pieces enforce it, both applied on every boot:
/etc/apt/apt.conf.d/99dev-vm-no-auto-upgrades— everyAPT::Periodickey set to0, read after the packaged20auto-upgrades.no-auto-upgrades-system.sh— masksapt-daily.timer,apt-daily-upgrade.timer, their services andunattended-upgrades.service. Masked, not disabled: a package upgrade can re-enable a disabled unit, and a masked one cannot be started even while it is enabled.
Upgrading is a deliberate command. Everything apt-installed — the OS, Docker, git (git-core PPA), Go (longsleep PPA), mise, zsh and the neovim build toolchain — comes from one call (Rust is not apt-installed; see below):
limactl shell <name> sudo apt-get update
limactl shell <name> sudo apt-get upgradeneovim is not an apt package (official release tarball), so it has its own installer, the same one boot provisioning runs. It resolves the latest release, compares it with the installed binary and does nothing when they match:
limactl shell <name> sudo /usr/local/lib/dev-vm/install-neovimRust is neither: rustup owns it, per user, and boot provisioning leaves an existing toolchain alone (Rust toolchain):
limactl shell <name> rustup updateA kernel upgrade takes effect on the next restart
(go run . stop <name> && go run . start <name>). Confirm nothing is
scheduled behind your back:
limactl shell <name> systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer
limactl shell <name> ls /etc/cron.dThe VM is cheap to replace, so the other upgrade path is
go run . delete <name> && go run . create <name>: a fresh create installs
current versions of everything.
The disk defaults to 50 GiB, and Docker images plus build cache are what fill it. Nothing prunes them on a schedule — no cron runs in this VM — so reclaiming space is a command you run when the disk gets tight:
limactl shell <name> docker system prune -af --filter "until=168h"- Removes stopped containers, unused images, unused networks and build cache
older than a week. Volumes are never touched without
--volumes, so database and cache data survives. untilfilters on creation time, not last use, so an unused base image built months ago goes on the first run. Anything a running container uses stays.- Drop the filter (
docker system prune -af) to take everything unused, ordocker builder prune -affor the build cache alone.
Lima reports the disk size, not its usage, so go run . list cannot show how
full it is. Ask the guest:
limactl shell <name> docker system df # -v for a per-image breakdown
limactl shell <name> df -h /Resizing is the painful part: cpus, memory and disk are baked into
~/.lima/<name>/lima.yaml at create time. Pick the size up front —
go run . create myvm -disk 100, or the disk key of the default block in
~/.config/dev-vm/settings.json for every VM (or of a vms block for one). Afterwards the only paths are
limactl stop <name> plus limactl edit <name> to raise disk: (Lima grows a
disk, never shrinks it), or go run . delete myvm && go run . create myvm -disk 100, which throws the guest away.
Every step runs on each boot (all scripts are idempotent). The install
scripts skip their apt and download work once everything is in place, so only
the first boot goes to the network and restarts stay fast; nothing here
upgrades anything (upgrading the guest). The template
is flattened at create time, so after editing lima/dev-vm.yaml or
lima/scripts/*.sh the VM must be recreated.
Order: data files, then system scripts (root), then user scripts (guest login
user), then readiness probes gate limactl start.
- Data files — GitHub key + ssh config, the global
DOCKER_HOSTsnippet (/etc/profile.d/docker-host.sh), theDEV_VM/DEV_VM_NAMEmarkers (/etc/profile.d/dev-vm.sh), the rustup PATH entry (/etc/profile.d/rust.sh), the no-auto-upgrade policy in/etc/apt/apt.conf.d/99dev-vm-no-auto-upgrades, the sysctls (/etc/sysctl.d/99-dev-vm.conf: unprivileged ports from 0 andnet.ipv4.ping_group_range, both of which plain mode stops Lima from setting itself), the neovim installerneovim-system.shruns and the operator reruns to upgrade (/usr/local/lib/dev-vm/install-neovim), and themy-iphelper (/usr/local/bin/my-ip, prints the guest IP from inside the VM), and the repository listclone-user.shreads (/usr/local/lib/dev-vm/clone-list, rendered bydevvm createfrom theclonesetting), and the mkcert root CA pair (/usr/local/lib/dev-vm/rootCA.pemandrootCA-key.pem, both empty unless themkcertsetting is on), and the base64xterm-ghosttyterminfo dump (/usr/local/lib/dev-vm/xterm-ghostty.terminfo.b64, empty unless theghosttysetting is on). firewall-system.sh— keeps the guest network open, first of the system scripts: installsnftableswhen missing, deletes theinet/ip/ip6 filtertables (nevernft flush ruleset— Lima'stable ip natcarries theLIMADNSchains), disables, stops and masksufw.service, thensysctl --systemto applynet.ipv4.ip_unprivileged_port_start=0. Rootless Docker's own rules live in its user network namespace and are untouched. See networking.rootless-base-system.sh— restores the rootless prerequisites that plain mode drops. Lima'sboot.shrunsboot.essential.Linux/*and then skips everyboot.Linux/*script, includingboot.Linux/20-rootless-base.sh, so this script does its work instead: adds the guest user's/etc/subuidand/etc/subgidranges (without themdockerd-rootless-setuptool.shfails withcould not find <user> in /etc/subuid), dropsDelegate=yesinto/etc/systemd/system/user@.service.d/, startssystemd-logindand enables linger for the user so the daemon's systemd user instance survives logout.docker-system.sh— installs Docker Engine from Docker's apt repo (withdocker-ce-rootless-extrasandslirp4netns) when any of it is missing, then masks the system-widedocker/containerdunits so only the rootless daemon exists.kvm-system.sh— puts the login user in thekvmgroup when/dev/kvmexists, so a nested guest is reachable without sudo. No-op on a VM created without nesting; see nested virtualization.git-system.sh— installs git from the git-core PPA (ppa:git-core/ppa, upstream releases) when the PPA or git is missing.go-system.sh— installsgolang-gofrom the golang-backports PPA (ppa:longsleep/golang-backports, current upstream Go) when the PPA or the package is missing.zsh-system.sh— installs zsh when missing,chshthe guest user to zsh, and sources/etc/profile.d/docker-host.sh,/etc/profile.d/dev-vm.shand/etc/profile.d/rust.shfrom/etc/zsh/zshenvso non-login zsh (limactl shell <name> <cmd>) also getsDOCKER_HOST,DEV_VM,DEV_VM_NAMEand~/.cargo/binonPATH.mise-system.sh— installs mise from its apt repo when it is missing.neovim-system.sh— installscurlplus the plugin build toolchain the tarball does not ship (tree-sitter-cliandbuild-essentialfornvim-treesitterparsers,luarockswithluajitfor Lua rocks) when any of it is missing, then — only when/usr/local/bin/nvimis absent — runs/usr/local/lib/dev-vm/install-neovim, which unpacks the official pre-built archive into/opt/nvim-linux-<arch>(x86_64orarm64, picked fromuname -m) and links it at/usr/local/bin/nvim. The same installer is also the upgrade path, so first boot and a latersudo /usr/local/lib/dev-vm/install-neovimshare one code path.packages-system.sh— installs the rest of the toolchain from the Ubuntu archive, the packages that need no third-party repo:tig,postgresqlwithlibpq-dev, andlibnss3-tools(certutil, for trusting a local CA). Skipped entirely once all of them are installed.ghostty-terminfo-system.sh— decodes the stagedxterm-ghosttyterminfo dump and compiles it withtic -xinto/usr/share/terminfo, installingncurses-binfirst if the image lackstic. No-op when the staged file is empty; see ghostty terminfo.no-auto-upgrades-system.sh— masksapt-daily.timer,apt-daily-upgrade.timer, their services andunattended-upgrades.service, so Ubuntu's stock automatic upgrades never fire. The matchingAPT::Periodiczeros ship as the data file above; see upgrading the guest.ssh-known-hosts.sh— rewrites~/.ssh/known_hostsfrom livessh-keyscan github.comoutput.ssh-config-user.sh— creates~/.ssh/config.dand installs the staged GitHub stanza as10-github.conf. It does not touch~/.ssh/config; see GitHub SSH key.omz-user.sh— installs oh-my-zsh (skipped if~/.oh-my-zshexists).dotfiles.sh— clones the bare repo to~/.dotfilesand checks it out over$HOME(clobbered files move to~/tmp/config-backup). No-op withoutDOTFILES_REPO. The repo owns~/.ssh/config, so it is also responsible for including the drop-ins above.docker-user.sh—dockerd-rootless-setuptool.sh install, then selects therootlesscontext. No networking configuration: the daemon defaults toslirp4netns+ thebuiltinport driver becausedocker-system.shinstalled theslirp4netnspackage — see publishing ports.mise-user.sh— activates mise in~/.zshrc(unless the oh-my-zsh mise plugin already does),mise trust --all,mise install.rust-user.sh— installs the Rust toolchain with rustup (--profile minimal, stable), unless~/.cargo/bin/cargois already there. See Rust toolchain.mkcert-user.sh— installs the staged root CA pair into the guest CAROOT (~/.local/share/mkcertunlessCAROOT/XDG_DATA_HOMEsays otherwise), after mise somkcert -CAROOTcan answer for itself. No-op when the staged files are empty; see mkcert root CA.clone-user.sh— clones the repositories from theclonesetting into<basedir>/<repo>, last so the ssh key,known_hostsand git are all in place.${HOME}inbasedirexpands here, in the guest. An existing directory is skipped and a failing clone is logged and skipped, so neither holds up the rest.
flowchart TD
subgraph data["data files (copied by root)"]
d1["~/.ssh/id_ed25519<br>GitHub SSH key<br>ssh-github.conf<br>staged config.d drop-in"]
d2["/etc/profile.d/docker-host.sh<br>DOCKER_HOST for libraries<br>/etc/profile.d/dev-vm.sh<br>DEV_VM + DEV_VM_NAME markers<br>/etc/profile.d/rust.sh<br>~/.cargo/bin on PATH"]
d3["apt.conf.d/99dev-vm-no-auto-upgrades<br>APT::Periodic all zero"]
d4["install-neovim<br>neovim tarball installer<br>(boot + manual upgrade)"]
d5["my-ip<br>prints the guest IP"]
d6["sysctl.d/99-dev-vm.conf<br>unprivileged ports from 0,<br>ping_group_range"]
d7["clone-list<br>repositories to clone<br>(from the clone setting)"]
d8["rootCA.pem + rootCA-key.pem<br>mkcert root CA<br>(from the mkcert setting)"]
d9["xterm-ghostty.terminfo.b64<br>terminfo dump<br>(from the ghostty setting)"]
end
subgraph system["system scripts (root)"]
s0["firewall-system.sh<br>drop filter tables, mask ufw,<br>apply the open-ports sysctl"]
s0b["rootless-base-system.sh<br>subuid/subgid, cgroup delegation, linger<br>(plain mode skips Lima's own)"]
s1["docker-system.sh<br>Docker packages, mask system daemon"]
s1a["kvm-system.sh<br>add the user to the kvm group<br>(no-op without nesting)"]
s1b["git-system.sh<br>install git from the git-core PPA"]
s1c["go-system.sh<br>install golang-go from the backports PPA"]
s2["zsh-system.sh<br>install zsh, set login shell,<br>hook the profile.d files into /etc/zsh/zshenv"]
s3["mise-system.sh<br>install mise from apt repo"]
s4["neovim-system.sh<br>install neovim from the release tarball<br>+ tree-sitter-cli, build-essential,<br>luarocks and luajit"]
s4b["packages-system.sh<br>tig, postgresql, libpq-dev,<br>libnss3-tools from the archive"]
s4c["ghostty-terminfo-system.sh<br>tic -x the xterm-ghostty entry<br>into /usr/share/terminfo"]
s5["no-auto-upgrades-system.sh<br>mask the apt-daily timers and<br>unattended-upgrades.service"]
end
subgraph user["user scripts (login user)"]
u1["ssh-known-hosts.sh<br>pin github.com host keys"]
u1b["ssh-config-user.sh<br>install the config.d GitHub drop-in"]
u2["omz-user.sh<br>install oh-my-zsh"]
u3["dotfiles.sh<br>check out dotfiles over $HOME"]
u4["docker-user.sh<br>set up rootless Docker daemon"]
u5["mise-user.sh<br>trust config, install tools"]
u5r["rust-user.sh<br>install the rustup toolchain"]
u5b["mkcert-user.sh<br>install the root CA into<br>the guest CAROOT"]
u6["clone-user.sh<br>clone the repositories from<br>the clone setting"]
end
subgraph probes["readiness probes"]
p1["docker info answers"]
p2["github.com accepts SSH key"]
end
data --> system
s0 --> s0b --> s1 --> s1a --> s1b --> s1c --> s2 --> s3 --> s4 --> s4b --> s4c --> s5
system --> user
u1 --> u1b --> u2 --> u3 --> u4 --> u5 --> u5r --> u5b --> u6
user --> probes