Skip to content

Tired of updating Docker for Mac. apple/container is enough.

Published
updated
Reading time
9 min read
  • macos
  • docker
  • container
  • apple-silicon
Contents

I switched from Windows to macOS years ago, and one of the things I never bothered to write up is how much calmer it made my computer. Windows always had this background buzz: “updates available”, “restart to install”, “we’re configuring your PC, please don’t turn it off”. macOS isn’t perfect, but most days the OS gets out of the way. A yearly release, the occasional security update, and that’s about it.

For a long time the one thing that broke that calm on my Mac was Docker Desktop.

It felt like every other Monday Docker wanted me to update. The little badge in the menu bar. The next time I ran docker something, the daemon was restarting, my containers were gone, whatever I had glued around it was confused. macOS was calm; Docker Desktop was Windows.

A few months ago I tried apple/container and stopped looking back.

What it is

apple/container is Apple’s open-source container runtime for macOS. Each Linux container runs in its own lightweight VM via the macOS virtualization framework. No system-wide daemon. No menu bar icon. No QEMU layer pretending to be Linux. Apache-2.0, written in Swift, optimized for Apple silicon.

It’s still pre-1.0 (0.12 as I write this), and Apple ships it from apple/container directly. Updates land when there are real changes — not because some product manager decided this was the week to push another release.

Getting it set up

1. Install Homebrew (if you don’t have it)

Homebrew is the package manager macOS doesn’t ship with. It’s how most of us install command-line tools: a single brew install <name> instead of hunting for installers. If you’ve used apt on Linux, it’s the closest macOS equivalent.

If you don’t already have it:

bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

On Apple Silicon, the installer puts everything under /opt/homebrew and then prints two lines you need to add to your shell so that brew is on your PATH. Copy them, paste them, restart your terminal — done.

2. Install apple/container

bash
brew install container
brew services start container

brew services start registers the launchd job that runs the apiserver in the background. There’s no menu bar icon. The only place you’ll know it’s running is when you check brew services list.

A couple of macOS-version notes before you go further:

  • macOS 26 (Tahoe) is the supported target. It runs on macOS 15 (Sequoia), but the docs are blunt: issues that can’t be reproduced on macOS 26 typically won’t be fixed.
  • Apple silicon only. No Intel Mac support.

3. Configure it

The defaults are deliberately small — containers get 1 GB RAM and 4 CPUs, and the builder VM gets just 2 GB RAM and 2 CPUs. The first real container build I ran was painful enough to send me straight to the docs.

Here’s what I run once on a new machine:

bash
sudo container system dns create dev.internal
container system property set network.subnet 192.168.64.0/24
container system property set dns.domain dev.internal
container system property set build.rosetta true
container system property set build.cpus 8
container system property set build.memory 16G
container system property set container.cpus 8
container system property set container.memory 8G
container system kernel set --recommended
container builder start

What each line does:

  • sudo container system dns create dev.internal — registers dev.internal as a resolver domain on the host. Needs sudo because it writes to /etc/resolver/. After this, anything ending in .dev.internal resolves to a container.
  • container system property set network.subnet 192.168.64.0/24 — the IPv4 CIDR for the container bridge. The default tends to collide with home networks (especially anything Eero / Apple AirPort-shaped). Pick something unused.
  • container system property set dns.domain dev.internal — the DNS suffix every container gets. A container started as outcoldman becomes outcoldman.dev.internal. This is the killer feature; we’ll come back to it.
  • container system property set build.rosetta true — runs amd64 layers on arm64 via Rosetta instead of QEMU during image builds. The speed difference is large.
  • container system property set build.cpus 8 and build.memory 16G — resources for the builder VM (BuildKit, the thing that actually executes RUN lines in a Dockerfile). Defaults are 2 / 2.
  • container system property set container.cpus 8 and container.memory 8G — defaults applied to every new container that doesn’t pass --cpus/--memory explicitly. Defaults are 4 / 1 GB.
  • container system kernel set --recommended — downloads Apple’s recommended Linux kernel build (with VIRTIO drivers etc.) and sets it as default. Without this you may end up on an older kernel that has its own quirks.
  • container builder start — starts the BuildKit VM, which is separate from per-container VMs and stays running between builds. You only need to do this once per machine.

These persist across reboots.

About the memory numbers

If you’re nervous about handing 16 GB to the builder and 8 GB to each container, don’t be — that’s not how the macOS Virtualization framework allocates memory. memory is an upper bound, not a hard reservation. The host doesn’t actually claim 16 GB the moment the VM starts; pages are wired in lazily as the guest touches them, and they’re returned to the host (via the VirtIO memory balloon) when the VM doesn’t need them. In practice you’ll see Activity Monitor show a few hundred MB for an idle builder, ramping up only during heavy RUN steps.

So: pick numbers based on your peak needs, not what you can spare on average. On a 36 GB Mac running 8 / 16 for the builder is comfortable. On a 16 GB Air I’d dial it down to 4 / 4.

What’s nice

No daemon nagging me. No menu bar icon. brew services is the only thing that tracks it, and brew upgrade is the only thing that updates it.

Per-container DNS. Every project I run is reachable at <name>.dev.internal. No port mapping, no host.docker.internal. Hugo running in a container called outcoldman is at http://outcoldman.dev.internal/ — I type it into the address bar and it works.

Native arm64 + Rosetta when I need it. Each container is its own VM, but they’re cheap, and on benchmarks Apple’s CPU and memory throughput edge ahead of Docker Desktop’s shared-VM model.

Open source, Apple-maintained. I can read the source. I can file issues directly. Apache-2.0.

No Docker subscription. Not a personal pain for me, but for small teams that hit Docker’s paid tier, this is real money saved.

Docker shortcuts that won’t carry over

The CLI is close enough to docker that muscle memory mostly works, but there are real differences I keep hitting:

  • docker pscontainer ls. No ps alias.
  • docker-compose / docker compose is not first-class. Multi-service stacks are doable but you’ll glue them together yourself or watch the third-party tools catch up.
  • --network host isn’t supported. With per-container DNS you usually don’t need it, but the rewrite is real.
  • --restart=always / --restart=on-failure aren’t supported. If you want a container to come back after a crash or reboot, brew services can wrap it, or you write a small launchd plist.
  • --privileged isn’t supported. Use --cap-add NET_ADMIN (etc.) for the specific capability instead. Cleaner anyway.
  • Health checks aren’t implemented. --health-cmd, --health-interval — gone for now.
  • --add-host is limited. Inject DNS via --dns, --dns-domain, --dns-search, but adding a single hosts-file entry isn’t there.
  • --link isn’t supported. Already deprecated in Docker, no loss.
  • Anonymous volumes don’t auto-cleanup with --rm. Docker reaps them; apple/container doesn’t. container volume ls and container volume rm regularly.

A Makefile that works in either runtime

Because I sometimes still reach for Docker (CI parity, working on a teammate’s machine), my Makefile auto-detects whichever runtime is on PATH and uses that. apple/container’s CLI is similar enough to Docker’s that this works for the basics:

makefile
# Prefer apple/container, fall back to docker if it's the only thing installed.
CONTAINER ?= $(shell command -v container 2>/dev/null || command -v docker 2>/dev/null)

NAME   ?= myapp
DOMAIN ?= dev.internal
HOST   := $(NAME).$(DOMAIN)
IMAGE  ?= $(NAME):dev

.PHONY: image
image:
	@test -n "$(CONTAINER)" || { echo "no container runtime found"; exit 1; }
	$(CONTAINER) build -t $(IMAGE) .

.PHONY: serve
serve: image
	@$(CONTAINER) delete --force $(NAME) >/dev/null 2>&1 || $(CONTAINER) rm -f $(NAME) >/dev/null 2>&1 || true
	$(CONTAINER) run -d \
		--name $(NAME) \
		$(if $(findstring container,$(CONTAINER)),--dns-domain $(DOMAIN),-p 8080:80) \
		-v "$(CURDIR)":/src \
		$(IMAGE)
	@echo "running at http://$(HOST)/ (apple/container) or http://localhost:8080/ (docker)"

.PHONY: stop
stop:
	@$(CONTAINER) delete --force $(NAME) >/dev/null 2>&1 || $(CONTAINER) rm -f $(NAME) >/dev/null 2>&1 || true

The conditional $(if $(findstring container,$(CONTAINER)),--dns-domain ...,-p 8080:80) is the awkward bit — apple/container wants --dns-domain for the auto-DNS, docker wants -p for port mapping. Live with the ternary or split the targets.

For real production parity (e.g., your CI runs docker compose up), keep the docker path; this dual-runtime trick is for the day-to-day “run this thing, hit it from a browser” loop.

Gotchas

It’s pre-1.0. Real ones to know before you commit:

  • Container startup is slower than Docker. Docker Desktop wins cold-start latency by a wide margin (sub-second vs ~1s). Each apple/container is a real VM. For tight TDD loops where you tear down and rebuild constantly, you’ll feel it.
  • Multi-container networking has rough edges. Container-to-container traffic on the same bridge isn’t bulletproof. I’ve seen DNS hiccup after sleep/wake; container system stop && start resets it. Networking improvements are tied to macOS 26.
  • Small-file I/O can be slower. Each container’s filesystem is a real EXT4 block device — great for sequential reads/writes, less great for npm install against a node_modules with 80,000 files. Bind mounts to host directories are the worst case.
  • Rosetta + kernel 6.13 has known regressions. Some recent Fedora/Debian images segfault under Rosetta. Either pin to an older base image or disable Rosetta and accept the QEMU cost.
  • dns.domain has macOS-side caveats. Creating a localhost domain disables iCloud Private Relay. The packet filter rule for the domain is also removed on macOS reboot, so you may need to re-run container system dns create occasionally.
  • Pre-1.0 means breaking changes. Minor version bumps may break things until 1.0. Pin a version in CI if you use this in a team.

When I’d still reach for Docker Desktop

  • Heavy multi-service Compose stacks
  • Devcontainers from VS Code or Cursor
  • Workflows that lean on Docker-specific networking magic (--network host, --link)
  • Anything where production parity with a Docker-based CI is more important than ergonomics

For everything else — running a Hugo dev server, spinning up a one-off Postgres, building images, prototyping a service — apple/container does what I want, and the menu bar stays quiet.

The Mac’s calm is back.