Container Machines
"Container machine" is the underappreciated abstraction in this tool — and once you use one, you stop reaching for container run for everyday Linux work.
Key Takeaways
- A
container machineis a persistent Linux environment built from an OCI image, not a one-shot container. Stop it, start it, your files and your user account are still there. - The container machine auto-mounts your host
$HOMEat/Users/<you>and matches your host UID/GID, so your dotfiles, SSH agent, and git config work without a copy step. - It runs
/sbin/init, which means systemd (or whatever init the image ships) actually works — you cansystemctl start postgresqlinside the machine like a real Linux server. - The decision matrix is sharper than people realize:
container runfor server-shaped work that should be ephemeral;container machinefor development-shaped work that should be persistent.
Why a second abstraction
I dismissed container machines on first read. "It's just a persistent container," I thought. Then I spent a week using one to write Go, and the dismissal collapsed. A container machine is not a persistent container the way a long-running container run --name foo alpine sleep infinity is a persistent container. The latter still requires you to manually wire up your home directory, manually configure your UID inside the guest, manually start every service, and manually manage what survives stop. The former does all of that for you, declaratively, on container machine create.
The architecture from Chapter 2 gives you the machinery to make this work. A container machine is one of the container-runtime-linux.<id> helpers from Chapter 2, with two differences:
- It is configured to stay alive. The init process is the image's
/sbin/init, not your application. The container's lifecycle is decoupled from any one process. - It provisions a user that matches you. The first-boot setup script reads your host UID/GID/username/home and creates an equivalent account inside the VM, with your
$HOMEalready bind-mounted in.
That's it. The architectural change is small. The ergonomic change is enormous.
The shape of the feature
You can read the entire feature surface in three commands, lifted from docs/container-machine.md:
container machine create alpine:latest --name dev
container machine run -n dev whoami # your host username, not root
container machine run -n dev pwd # /home/<you> — your Mac home dir, mounted in
container machine run -n dev # interactive shell; cd into your repos in $HOME
After the first command, the machine exists. After the second, you have an interactive shell as you with your files. Stop the machine (container machine stop dev) and start it again (container machine run -n dev whoami) — same user, same home, same dotfiles. This is the dev-environment-as-OCI-image pattern, and it's something no other major macOS container tool delivers as cleanly out of the box.
Three things make it feel native rather than bolted-on:
1. The home-mount is automatic. You don't cp -r your dotfiles in. You don't write a Dockerfile that COPYs them. The container machine's first-boot provisioning script bind-mounts ~ into the guest and configures /etc/passwd so the guest user points at it. 2. The UID/GID match by default. Files you create inside the machine are owned by your host UID, so your host filesystem stays consistent. No chown -R 1000:1000 after every container rebuild. 3. /sbin/init actually runs. Your distro's init system boots. systemd, OpenRC, whatever the image ships. You can run a database as a system service (systemctl start postgresql) the way you would on a real Linux server, instead of backgrounding processes manually.
When container machine beats container run
Picture two workflows.
Workflow A — backend service in CI. A web server image that builds, runs, exits. You want a clean, isolated, reproducible environment that doesn't accumulate state between runs. You want it torn down after the test, not lingering. You want fast startup because the CI matrix creates dozens per minute.
Workflow B — day-long dev session. You're writing Go. You want a Linux toolchain (gcc, make, golang.org/x/tools/cmd/goimports) that runs natively on Linux but sees your actual repository. You want to leave at 6pm, close the laptop, open it at 9am, and have the toolchain still be there with your editor buffers still in your Mac's $HOME. You want to run a Postgres for integration tests without thinking about how it was started.
container run is the right tool for Workflow A. container machine is the right tool for Workflow B. The same per-container VM machinery backs both; the difference is whether the container's lifecycle is bound to a single process or to a long-lived init system.
flowchart TB
Q{Need persistent<br/>Linux state<br/>across runs?}
Q -->|Yes| M[container machine<br/>+ init + home mount]
Q -->|No| R[container run<br/>ephemeral]
M --> U1[Long dev sessions]
M --> U2[Real Linux services<br/>systemctl postgresql]
M --> U3[Mounted home dir<br/>dotfiles survive]
R --> U4[CI / batch jobs]
R --> U5[One-off tools]
R --> U6[Test isolation]
The decision isn't "which tool is better." It's "which lifecycle do I want?"
The bring-your-own-image escape hatch
The default Alpine-based container machine is fine for many purposes. But if you want a Debian-flavored machine with a specific set of tools preinstalled, you build your own image and use that. The docs spell out a complete Dockerfile pattern in docs/container-machine.md:
FROM ubuntu:24.04
ENV container container
RUN apt-get update && \
apt-get install -y \
dbus systemd openssh-server net-tools iproute2 \
iputils-ping curl wget vim-tiny man sudo && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
yes | unminimize
RUN >/etc/machine-id
RUN >/var/lib/dbus/machine-id
RUN systemctl set-default multi-user.target
RUN systemctl mask \
dev-hugepages.mount \
sys-fs-fuse-connections.mount \
systemd-update-utmp.service \
systemd-tmpfiles-setup.service \
console-getty.service
RUN systemctl disable networkd-dispatcher.service
This is doing real work: enabling systemd as PID 1, masking services that don't make sense in a container, disabling networkd so it doesn't fight the vmnet-attached interface. Building and using it:
container build -t local/ubuntu-machine:latest .
container machine create local/ubuntu-machine:latest --name ubuntu
The first command uses the same builder that container build for application images uses (a gRPC-controlled build VM running BuildKit, more on that in a moment). The second command creates a container machine from your custom image. You now have an Ubuntu 24.04 dev environment with systemd, OpenSSH server, vim, curl, sudo, and a sane man page setup — running on a Mac, with your home directory mounted in, your UID matched, and a virtio-attached network interface on the vmnet default subnet.
This is what a Mac-native Linux dev environment actually looks like in 2026.
Why this matters for how you think about the tool
A container machine is a place to stay, not a place to visit. That distinction rewires how you budget your attention as a developer:
- Stateful scratch becomes cheap.
apt install ripgrepinside the machine survivescontainer machine stopandcontainer machine start. You don't fight with Docker layers or rebuild on every dependency tweak. - System services become reachable. Postgres, Redis, a local Kafka — anything you'd reach for in a real environment — runs as a service the way you'd expect, not as a backgrounded shell command.
- Editor integration is trivial. Your Mac-side VS Code / Zed / Neovim edits files in your
~, which is bind-mounted in the machine. There's no copy step, no sync, no "save then run inside the container" workflow. You edit on the Mac; you build and run inside the Linux machine. - Multi-distro testing becomes one command per distro.
container machine create alpine:3.20 --name alpine-dev,container machine create fedora:41 --name fedora-dev,container machine create debian:bookworm --name debian-dev. Same~mapping. Same workflow. Different distros. You can verify your application boots on all of them in an afternoon.
That's the value proposition. None of it is impossible to achieve with container run plus a few well-placed volume mounts and a manually-curated Dockerfile. But it is impossible to achieve with that approach without significant per-project glue, and the glue is precisely what the container machine pattern provides for free.
Where it stops helping
Honest limits, before we move on:
- A container machine still consumes its VM's memory, which doesn't return to the host. Idle, a dev machine with your editor running is fine. Two of them idle with editor sessions open in both, plus a database, and you're feeling the tax.
- The kernel is shared across the host's macOS version's vmnet limits. On macOS 15, container machines can't talk to each other across networks — the
vmnetframework only provides isolation, not inter-vm routing. On macOS 26, the multi-network model works. - The systemd pattern is image-dependent. An Alpine-based container machine won't run
systemctl. You picked Alpine; you opted out of systemd. The default Alpine setup uses OpenRC. This is correct behavior, but if you assume "container machine = systemd," you'll be briefly surprised.
These limits are real and they're the same limits the per-container VM model carries everywhere. The container machine ergonomic doesn't escape the architecture; it leans into it.
The next chapter lays the trade-off ledger bare — what container is honest about, what it isn't, and how to make the pick between it and its competitors with a clean head.