Skip to content
robsonpeixotoPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

dev-vm

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 inside

See CLAUDE.md for how Lima provisioning works in detail.

Requirements

  • macOS with Lima 2.0.0+ (limactl).
  • GitHub CLI (gh), logged in with the admin:public_key scope: gh auth refresh -h github.com -s admin:public_key.
  • Go 1.27.1+ (the go line in go.mod), to run the CLI with go run . (not needed for a released binary).

Install

Homebrew

The formula lives in the robsonpeixoto/homebrew-tap tap (Formula/dev-vm.rb there):

brew tap robsonpeixoto/tap
brew install robsonpeixoto/tap/dev-vm

Later: 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.

From a release

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-vm

The 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.

Usage

  1. Create the VM (name defaults to default):

    go run . create myvm

    This generates an SSH key, registers it on GitHub, boots the VM (2 vCPUs, 2 GiB RAM, 50 GiB disk) and runs provisioning.

  2. Optional — bring your dotfiles (bare repo checked out over $HOME):

    go run . create myvm -dotfiles git@github.com:user/dotfiles.git

    Put "dotfiles": "<repo>" in the default block of ~/.config/dev-vm/settings.json (see step 5) to enable it for every VM; -no-dotfiles skips it.

  3. Optional — size the VM. -memory and -disk are plain integers in GiB:

    go run . create myvm -cpus 8 -memory 16 -disk 100

    The 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 means go run . delete myvm && go run . create myvm -cpus ….

  4. Optional — nested virtualization, for running VMs inside the dev VM:

    go run . create myvm -nested

    Needs an Apple M3 or later, and like the size it is fixed at create time. See nested virtualization.

  5. Optional — settings.json. ~/.config/dev-vm/settings.json holds a default block that applies to every VM, plus a vms block keyed by VM name that overrides it key by key. clone lists repositories to clone in the guest, per GitHub org, with the directory they go under, mkcert copies the host mkcert root CA into the VM, ghostty installs the xterm-ghostty terminfo entry in it, nested gives the guest /dev/kvm and mounts shares 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-vm gets 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 the default block 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.

    basedir is a guest path; ${HOME} (or $HOME) in it expands in the guest. Each repository is cloned over SSH as git@github.com:<org>/<repo>.git into <basedir>/<repo>, as the last user provisioning step, so the provisioned key and known_hosts are 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": true needs mkcert on the host with a CA already generated (mkcert -install); create fails with a hint otherwise. See mkcert root CA.

    "ghostty": true needs Homebrew's ncurses on the host (brew install ncurses). See ghostty terminfo.

    mounts entries take a host source, a guest destination and a mode. See host mounts.

    "nested": true needs an Apple M3 or later. See nested virtualization.

  6. 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
  7. Check what exists — name, Lima status, size, guest IP, SSH hostname:

    go run . list
    NAME    STATUS   CPUS  MEM   DISK   IP              SSH
    myvm    Running  4     8GiB  50GiB  192.168.64.26   lima-myvm
    

    IP is 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
  8. 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

    -ip prints 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.

  9. Stop and start it. A host reboot leaves every VM stopped; start boots it again, re-running provisioning and the readiness probes:

    go run . stop myvm
    go run . start myvm

    stop -force kills the VM instead of shutting the guest down gracefully — faster, but unwritten guest data is lost.

  10. 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 myvm

Scripts 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 -force

The 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/.

Networking

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. portForwards in lima/dev-vm.yaml ignores both 127.0.0.1 and 0.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.sh runs first on every boot: it deletes the inet/ip/ip6 filter tables, then disables, stops and masks ufw.service so a package upgrade or a dotfiles checkout cannot switch one back on. Lima's own table ip nat is left alone — its LIMADNS chains redirect guest DNS, and a blanket nft flush ruleset would 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 json carries only the MAC, and the macOS DHCP lease file keeps stale rows — so list and status run limactl 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-ip prints the same address (/usr/local/bin/my-ip): it reads lima0, 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 . list or go 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.

Publishing ports

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 IP

The 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.

Shell completion

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 | source

Homebrew 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.fish

The 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.

Releasing

.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 runs

format-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.0

A 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_TOKEN must hold a PAT with contents: write on the tap repo — github.token only reaches this repository.
  • Each sha256 line must stay two lines below its url line in the formula; the job fails loudly otherwise.

GitHub SSH key

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 ~/.ssh is never read; Lima does not load ~/.ssh/*.pub into the guest.
  • Guest: the private key is uploaded to ~/.ssh/id_ed25519, and the stanza pinning it for github.com with IdentitiesOnly yes is installed as ~/.ssh/config.d/10-github.conf.
  • ~/.ssh/config is never written by this repo. Loading the drop-ins is the dotfiles' job: the repo's ~/.ssh/config must carry Include ~/.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_ed25519 by default, but IdentitiesOnly yes is not in effect, so a dotfiles-supplied agent or extra key can be offered to GitHub first.
  • delete deletes the GitHub key, the local pair, the VM and the state entry, in that order, after confirming the VM name (-force skips the prompt). The irreversible step is last, and the token scope is checked first, so a gh failure aborts with the VM still there.

mkcert root CA

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.pem and rootCA-key.pem are copied into the guest CAROOT, which is ~/.local/share/mkcert unless CAROOT or XDG_DATA_HOME says 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.pem also goes into the template's caCerts.files, which hands it to cloud-init: it lands in /usr/local/share/ca-certificates and update-ca-certificates trusts it system-wide, so curl and every library using the system store accept guest-issued certificates with no extra flag.
  • The caCerts.files entry stored in ~/.lima/<name>/lima.yaml is the host path, and Lima re-reads it on every start (it regenerates cidata.iso from lima.yaml each time). Deleting or moving the host CAROOT therefore breaks go run . start <name> for that VM until the file is back or the entry is removed with limactl edit <name>.
  • Nothing runs mkcert -install in the guest. The system store is covered by caCerts above; trusting the CA in a guest browser's own NSS store is a manual mkcert -install (libnss3-tools is 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 toolchain

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 ~/.rustup and ~/.cargo, which keeps rustup update, rustup component add and cargo install working without sudo. cargo from the Ubuntu archive is deliberately not installed — crates raise their minimum Rust version far faster than an LTS archive moves, and two toolchains on one PATH is worse than none.
  • PATH comes from /etc/profile.d/rust.sh, so rustup runs with --no-modify-path and appends nothing to ~/.zshrc or ~/.profile, which the dotfiles own. zsh-system.sh wires that file into /etc/zsh/zshenv too, so non-login shells — and any nvim started from one — find cargo.
  • The rest of what a cargo build needs is already there: git (blink.cmp's build.rs runs git rev-parse HEAD) from git-system.sh and the linker from build-essential in neovim-system.sh. blink.cmp also wants Neovim 0.12+, which the release tarball provides; see provisioning steps.
  • --profile minimal means no clippy, rustfmt or rust-docs. Add what you want with rustup component add clippy rustfmt.
  • Boot provisioning never upgrades an existing toolchain — the script skips when ~/.cargo/bin/cargo exists. Upgrading is rustup update.

ghostty terminfo

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, so brew install ncurses is a requirement rather than a preference; create fails with that hint when the binary is not there.
  • The entry itself comes from wherever TERMINFO points, which is the app bundle when devvm runs inside Ghostty. Outside it, create retries with TERMINFO=/Applications/Ghostty.app/Contents/Resources/terminfo.
  • The dump is staged base64-encoded and ghostty-terminfo-system.sh pipes it through tic -x -o /usr/share/terminfo, as root: system-wide, so sudo and root shells get the entry too. base64 is not decoration — see CLAUDE.md on Lima templating mode: data content.
  • Nothing sets TERM in the guest: ssh and limactl shell carry 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.

Host mounts

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"}
]}}
  • create rejects a relative path, ~user, an unknown mode, and paths holding quotes, backslashes, braces or control characters; Lima itself rejects system destinations such as /etc, /usr or /home.
  • Like the size, mounts are fixed at create time: the list lands in the instance's lima.yaml. Change them with limactl 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.

Nested virtualization

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. create reads machdep.cpu.brand_string and 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: -nested turns it on for a VM the settings leave off, -nested=false turns it off for one the default block turns on.
  • Fixed at create time, like the size: the template is flattened into ~/.lima/<name>/lima.yaml when the instance is created. Changing it means limactl edit <name> or delete and create again.
  • Without it the guest kernel sees no virtualization extensions and creates no /dev/kvm at all, which is why kvm-system.sh is a no-op there.
  • /dev/kvm is root:kvm 0660, and the image does not put the login user in that group; kvm-system.sh does, on every boot. Group membership applies to the next login, which the next limactl shell already 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.

Guest OS

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.

Bumping to a new LTS

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.)

  1. Confirm the template exists and see what ubuntu-lts currently 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"
  2. Point the base: entry in lima/dev-vm.yaml at the new template:_images/ubuntu-XX.YY, and update the release named in this section and in CLAUDE.md.

  3. 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 — create finishes without a provisioning error, and limactl shell pinbump sudo cat /var/log/cloud-init-output.log shows 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.com reporting successfully authenticated.

Upgrading the guest

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 — every APT::Periodic key set to 0, read after the packaged 20auto-upgrades.
  • no-auto-upgrades-system.sh — masks apt-daily.timer, apt-daily-upgrade.timer, their services and unattended-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 upgrade

neovim 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-neovim

Rust is neither: rustup owns it, per user, and boot provisioning leaves an existing toolchain alone (Rust toolchain):

limactl shell <name> rustup update

A 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.d

The 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.

Disk hygiene

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.
  • until filters 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, or docker builder prune -af for 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.

Provisioning steps

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.

  1. Data files — GitHub key + ssh config, the global DOCKER_HOST snippet (/etc/profile.d/docker-host.sh), the DEV_VM/DEV_VM_NAME markers (/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 and net.ipv4.ping_group_range, both of which plain mode stops Lima from setting itself), the neovim installer neovim-system.sh runs and the operator reruns to upgrade (/usr/local/lib/dev-vm/install-neovim), and the my-ip helper (/usr/local/bin/my-ip, prints the guest IP from inside the VM), and the repository list clone-user.sh reads (/usr/local/lib/dev-vm/clone-list, rendered by devvm create from the clone setting), and the mkcert root CA pair (/usr/local/lib/dev-vm/rootCA.pem and rootCA-key.pem, both empty unless the mkcert setting is on), and the base64 xterm-ghostty terminfo dump (/usr/local/lib/dev-vm/xterm-ghostty.terminfo.b64, empty unless the ghostty setting is on).
  2. firewall-system.sh — keeps the guest network open, first of the system scripts: installs nftables when missing, deletes the inet/ip/ip6 filter tables (never nft flush ruleset — Lima's table ip nat carries the LIMADNS chains), disables, stops and masks ufw.service, then sysctl --system to apply net.ipv4.ip_unprivileged_port_start=0. Rootless Docker's own rules live in its user network namespace and are untouched. See networking.
  3. rootless-base-system.sh — restores the rootless prerequisites that plain mode drops. Lima's boot.sh runs boot.essential.Linux/* and then skips every boot.Linux/* script, including boot.Linux/20-rootless-base.sh, so this script does its work instead: adds the guest user's /etc/subuid and /etc/subgid ranges (without them dockerd-rootless-setuptool.sh fails with could not find <user> in /etc/subuid), drops Delegate=yes into /etc/systemd/system/user@.service.d/, starts systemd-logind and enables linger for the user so the daemon's systemd user instance survives logout.
  4. docker-system.sh — installs Docker Engine from Docker's apt repo (with docker-ce-rootless-extras and slirp4netns) when any of it is missing, then masks the system-wide docker/containerd units so only the rootless daemon exists.
  5. kvm-system.sh — puts the login user in the kvm group when /dev/kvm exists, so a nested guest is reachable without sudo. No-op on a VM created without nesting; see nested virtualization.
  6. git-system.sh — installs git from the git-core PPA (ppa:git-core/ppa, upstream releases) when the PPA or git is missing.
  7. go-system.sh — installs golang-go from the golang-backports PPA (ppa:longsleep/golang-backports, current upstream Go) when the PPA or the package is missing.
  8. zsh-system.sh — installs zsh when missing, chsh the guest user to zsh, and sources /etc/profile.d/docker-host.sh, /etc/profile.d/dev-vm.sh and /etc/profile.d/rust.sh from /etc/zsh/zshenv so non-login zsh (limactl shell <name> <cmd>) also gets DOCKER_HOST, DEV_VM, DEV_VM_NAME and ~/.cargo/bin on PATH.
  9. mise-system.sh — installs mise from its apt repo when it is missing.
  10. neovim-system.sh — installs curl plus the plugin build toolchain the tarball does not ship (tree-sitter-cli and build-essential for nvim-treesitter parsers, luarocks with luajit for Lua rocks) when any of it is missing, then — only when /usr/local/bin/nvim is absent — runs /usr/local/lib/dev-vm/install-neovim, which unpacks the official pre-built archive into /opt/nvim-linux-<arch> (x86_64 or arm64, picked from uname -m) and links it at /usr/local/bin/nvim. The same installer is also the upgrade path, so first boot and a later sudo /usr/local/lib/dev-vm/install-neovim share one code path.
  11. packages-system.sh — installs the rest of the toolchain from the Ubuntu archive, the packages that need no third-party repo: tig, postgresql with libpq-dev, and libnss3-tools (certutil, for trusting a local CA). Skipped entirely once all of them are installed.
  12. ghostty-terminfo-system.sh — decodes the staged xterm-ghostty terminfo dump and compiles it with tic -x into /usr/share/terminfo, installing ncurses-bin first if the image lacks tic. No-op when the staged file is empty; see ghostty terminfo.
  13. no-auto-upgrades-system.sh — masks apt-daily.timer, apt-daily-upgrade.timer, their services and unattended-upgrades.service, so Ubuntu's stock automatic upgrades never fire. The matching APT::Periodic zeros ship as the data file above; see upgrading the guest.
  14. ssh-known-hosts.sh — rewrites ~/.ssh/known_hosts from live ssh-keyscan github.com output.
  15. ssh-config-user.sh — creates ~/.ssh/config.d and installs the staged GitHub stanza as 10-github.conf. It does not touch ~/.ssh/config; see GitHub SSH key.
  16. omz-user.sh — installs oh-my-zsh (skipped if ~/.oh-my-zsh exists).
  17. dotfiles.sh — clones the bare repo to ~/.dotfiles and checks it out over $HOME (clobbered files move to ~/tmp/config-backup). No-op without DOTFILES_REPO. The repo owns ~/.ssh/config, so it is also responsible for including the drop-ins above.
  18. docker-user.sh — dockerd-rootless-setuptool.sh install, then selects the rootless context. No networking configuration: the daemon defaults to slirp4netns + the builtin port driver because docker-system.sh installed the slirp4netns package — see publishing ports.
  19. mise-user.sh — activates mise in ~/.zshrc (unless the oh-my-zsh mise plugin already does), mise trust --all, mise install.
  20. rust-user.sh — installs the Rust toolchain with rustup (--profile minimal, stable), unless ~/.cargo/bin/cargo is already there. See Rust toolchain.
  21. mkcert-user.sh — installs the staged root CA pair into the guest CAROOT (~/.local/share/mkcert unless CAROOT/XDG_DATA_HOME says otherwise), after mise so mkcert -CAROOT can answer for itself. No-op when the staged files are empty; see mkcert root CA.
  22. clone-user.sh — clones the repositories from the clone setting into <basedir>/<repo>, last so the ssh key, known_hosts and git are all in place. ${HOME} in basedir expands 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
Loading

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages