apple-container-guide
Migrating from Docker Desktop to Apple's container runtime on macOS
About
macOS 26 shipped apple/container, a container runtime that boots one lightweight VM per container rather than putting every container inside a single shared Linux VM the way Docker on macOS does. That one architectural difference explains nearly every behavioural difference you hit when you move across, and most of them are not in anyone's README.
This is the guide, the scripts and the worked examples for making that move — written while doing it, and corrected by what actually happened.
It covers the four tools that make up the ecosystem:
| Tool | Role |
|---|---|
| apple/container | the runtime |
| socktainer | Docker Engine API compatibility — the keystone for everything else |
| container-compose | Apple-native Compose front end |
| davit | native SwiftUI GUI, plus a genuinely useful headless CLI |
Why it's not just "install and carry on"
Docker runs one VM and puts your containers in it. Apple container gives each container its own kernel. The consequences:
| Docker Desktop | Apple container | |
|---|---|---|
| Idle cost | one VM always resident | nothing running when stopped |
--memory | soft cgroup limit, elastic | hard VM reservation at boot |
--cpus | CFS quota, fractional | whole vCPUs, fractions floored to 1 |
| Container IP | inside a NAT'd bridge | routable, reachable from the host with no --publish |
--network host | works | impossible — no shared netns |
--privileged | works | does not exist |
pause / unpause | works | no freezer |
| Startup | ~100 ms | ~1 s (a kernel boots) |
The --memory row is the one that costs people an afternoon. It is a reservation, not a ceiling, and the default is 1 GiB — so a Postgres that was comfortable on Docker Desktop is OOM-killed inside the guest, with nothing in the container logs to say so. The answer is container logs --boot, which has no Docker equivalent because Docker has no per-container VM to fail to boot.
What the measurements changed
Behaviour was verified against container 1.4.1, socktainer 1.2.1, container-compose 1.2.0 and Davit 0.1.36 on macOS 27 / Apple silicon, rather than taken from upstream documentation. Six findings differ from the docs, and each one is a thing that would otherwise be debugged from scratch:
brew services start socktainercannot work as documented. Homebrew's generated plist setsHOMEto a Homebrew directory. socktainer derives both its socket path and its Docker context file fromHOME, so the socket lands at$(brew --prefix)/var/run/socktainer/.socktainer/container.sockand the context is written where yourdockerCLI never looks.docker context use socktainerthen fails withcontext not foundand a path that is a bare hash. The guide ships a LaunchAgent with the correctHOME.docker composethrough socktainer does full service discovery. Bothdbanddb.myprojectresolve from inside a container with no host[dns]domain configured — socktainer runs its own DNS server. Healthcheck-gateddepends_ongenuinely waits, rather than merely ordering.container-composebreaks on string-formcommand:. Docker Compose word-splitscommand: sh -c '...'; container-compose passes the whole string as one argv element, and the container dies withNo such file or directorynaming your entire command. The list form works everywhere. This is the single most common porting failure and the error points nowhere useful.container-compose downonly stops. Containers stay instoppedstate and volumes are untouched — the opposite ofdocker compose down, which removes containers and keeps volumes unless you pass-v.container-composedoes honour volumedriver_optsandmem_limit, contrary to what its feature list implies, but ignorescpusand healthcheck conditions.container k8sreporting "plugin not found" is not a missing plugin. It is a mismatch between the install root the CLI resolves and the one the running apiserver came from. Fix the install and it starts working with no other change.
That last one generalises. Having two installs — the official .pkg in /usr/local and a Homebrew keg — is the most common broken state, because the XPC protocol between CLI and daemon is explicitly not stable across versions. The symptoms are bizarre and never point at versions, so the health check looks for it specifically.
What's in it
11 documents — install and which install method to pick, the migration sequence, a docker → container command and flag map including the flags with no counterpart, the three Compose front ends compared on measured behaviour, socktainer's compatibility surface, networking and DNS, storage, the GUI, the wider tool ecosystem, troubleshooting, and Testcontainers.
11 scripts, all re-runnable, the destructive one dry-run by default:
doctor.sh | read-only health check: mixed installs, client/daemon skew, DNS, contexts, stale sockets, competing runtimes, disk |
bootstrap.sh | Homebrew install of the whole toolchain, services, completions |
socktainer-service.sh | LaunchAgent that works around the brew services HOME problem |
switch-runtime.sh | point docker at Apple container, Docker Desktop or Colima |
setup-dns.sh | both halves of the DNS setup that everyone does only half of |
migrate-images.sh | registry pull, or docker save piped into socktainer's docker load |
migrate-volumes.sh | tar handoff with file-count verification |
compose-check.sh | lints a Compose file for what this platform cannot honour |
cleanup.sh | disk reclaim |
uninstall-docker-desktop.sh | the last step — dry run unless --execute |
Shell helpers for zsh and bash. Nothing shadows a real command — there is no cd alias, and "shell into a container" is cinto, because csh is the C shell. achelp lists the lot.
There is deliberately no docker → container alias shim. The two CLIs disagree on enough flags that a partial translation silently changes what your command means, which is worse than an error. Use socktainer when you want docker to work.
Seven examples, each runnable: the smoke test, ports and bind mounts, the same three-service stack through all three Compose front ends, the Postgres /lost+found trap, multi-platform builds, Testcontainers config, and local Kubernetes.
The /lost+found trap
Worth naming because it is unavoidable and the error message is unhelpful. Every Apple container named volume is ext4, and ext4 always contains a /lost+found directory. Postgres initdb refuses a data directory that is not empty:
initdb: error: directory "/var/lib/postgresql/data" exists but is not emptysocktainer strips it automatically for Postgres images. The container CLI does not, so with container-compose you set PGDATA to a subdirectory or clear it yourself. MySQL, MongoDB and Elasticsearch have variants of the same check.
Where it fits, and AGENTS.md
Apple container is one of seven credible ways to run Linux containers on a Mac, and the deciding factor is often licensing rather than architecture. The guide compares all of them — Docker Desktop, Colima, Lima, Podman, Rancher Desktop, Finch and OrbStack — on architecture, a capability matrix, and who owes money for commercial use, with a dedicated section on Podman since it is the closest philosophical sibling and the two get conflated.
It also ships an AGENTS.md, so a coding agent detects the runtime before assuming docker, and stops rather than spending turns on flags that cannot work here.
Both live in the repo: docs/12-alternatives.md · AGENTS.md
Should you migrate?
| Situation | Verdict |
|---|---|
| Web app plus Postgres / Redis / Kafka, Compose-based | Yes. This is the sweet spot. |
Mostly docker build and push | Yes. Same BuildKit. |
| Testcontainers suites | Probably. Ryuk must be disabled; it reaps by bind-mounting the Docker socket, which socktainer relays but cannot honour. |
--network host, --privileged with device access, GPU | No. Keep Docker Desktop or Colima for those projects. |
| Still on macOS 15 | No. Networking and DNS need macOS 26+. |
| Intel Mac | No, and never. Colima or Rancher Desktop instead. |
| Docker Desktop licence cost, all-Apple-silicon fleet | Yes — this is the strongest case for it. |
It is not a global decision. Both runtimes can be installed at once, and the active Docker context decides which one docker talks to — one command each way, which is what makes the migration safe to do gradually.
Status
| Runtime, socket compatibility, storage, networking, CLI mapping | Complete |
| Three Compose front ends, compared on measured behaviour | Complete |
| Testcontainers — Java, Kotlin, Node, Go, Python, .NET | Complete |
| Migration and rollback scripts | Complete |
Local Kubernetes via container k8s | Covered; the feature itself is experimental |
| Comparison against Podman, Colima, Rancher, OrbStack, Finch | Complete, including commercial-use licensing |
AGENTS.md for coding agents | Complete |
| Re-verification against future releases | Ongoing — the ecosystem moves weekly |
Every version number in the guide is stated, because on a stack this young a claim without one has a short shelf life.
