This is the English version of my Japanese post on Zenn.
Back in July I measured Apple's container runtime against 29 samples from Docker's official awesome-compose collection and concluded: not your main dev stack yet.
Something kept bugging me about that verdict, though. Official samples are polite. Real-world compose files are not: secrets live outside the file, services join a pre-existing reverse-proxy network, data sits in bind mounts under /mnt/docker-volumes because that's how the self-hosting community rolls. If you want the real compatibility picture, you have to feed the runtime the messy stuff.
So this time I fed it everything: all 161 entries of Haxxnet/Compose-Examples, the self-hosting community's staple collection. This post is the report — the counts, a taxonomy of why things fail, and what happened when I taught a tool to fix the mechanical failures automatically: full-stack startup went from 39% to 50%.
Setup
- Corpus: 161 entries, of which 159 have a compose file
- Parse/validate: all 159
- Startup test: the 156 without a
build:section (image-only), each started, classified, and torn down by a script - Two arms per project, back to back, on the same binary: A = plain
up, B =up --from-docker-compose(auto-fix on — more on that below) - Secrets and passwords left as placeholders. This is the deliberately hostile "run
upwithout reading the README" condition - 120-second hard timeout per
up(this timeout misclassifies exactly one project later — I'll get to it) - Environment: macOS 26 / Apple silicon,
container1.1.0, with opossum v0.15.0 as the orchestration layer — a thin translator from compose tocontainerCLI calls, so this sweep doubles as its compatibility test
Reproducing it is essentially this (drop --from-docker-compose for the no-fix arm):
container system start # once
sudo container system dns create opossum # once (name resolution)
opossum up --from-docker-compose # in each compose directory
opossum ps # classification comes from this output
opossum down -v # cleanup
Each run lands in one of six classes:
| Class | Meaning |
|---|---|
| FULL-UP | every service running |
| PARTIAL | some services running |
| CRASH | a container crashed right after start |
| STARTED-NONERUN |
up finished within 120s but nothing was running (instant exits, image pull failures — mixed, not individually audited) |
| PREFLIGHT | stopped before starting (host port taken, missing external network, …) |
| TIMEOUT | cut off by the 120s timeout |
The raw numbers first
Parsing: all 159 files load. Zero parse errors, zero unreadable files. Real-world compose files always contain fields Apple's runtime has no counterpart for — container_name (149 of them), restart (143) — so "ignore unsupported fields and list them in a warning" turns out to be exactly the policy that makes the whole corpus loadable.
The mass startup, no modifications (arm A):
| Class | Count |
|---|---|
| FULL-UP | 61 |
| PARTIAL | 21 |
| CRASH | 9 |
| STARTED-NONERUN | 40 |
| PREFLIGHT | 23 |
| TIMEOUT | 2 |
61 of 156 (39%) reach full startup with zero modifications: gitea, jellyfin, grafana, home-assistant, minio, syncthing, nginx-proxy-manager, code-server — the self-hosting staples just come up.
Read 39% as a conservative floor. Secrets are unfilled, so any stack that requires a DB password can't get past PARTIAL by construction. The 40 STARTED-NONERUN projects failed for mixed reasons (instant exits, pull failures) that I didn't audit one by one, so I can't even claim all 40 are "broken". Set things up the way the README says and the number only goes up.
For scale: the official 29-sample survey had 14 working as-is (48%). This corpus is measured under much harsher rules — no secrets, all services must be running — and still lands at 39%. My pre-sweep prediction that real-world files would do far worse than polished samples was wrong.
Counting the failures
Classifying the 95 failures gives two groups.
Group 1: nothing to do with Apple's container
Configuration that isn't there yet, essentially:
- Unset secrets crash the DB (the main source of PARTIAL — this is my test condition, not a bug)
- Host port already taken (5000/7000 is usually macOS's AirPlay Receiver; explicit mappings like
"5000:5000"fail identically under docker compose. The one real difference is bare ports like- "3000": docker compose escapes to a random host port, Apple's runtime requires an explicit one, so it mirrors the number and can collide) - Bind mounts of placeholder paths like
/path/to/my/data(you're meant to fill those in) -
externalnetworks that don't exist (you're meant to create the proxy network first)
For reference, the first sweep of this corpus counted the top diagnostics: 20 DB crashes from unset secrets, 12 host-port collisions, 12 placeholder-path bind failures, 10 missing external networks. All of these would need the same adjustments on Docker Desktop or on any other machine (with a caveat — see the limits section).
Group 2: failures that are Apple-container-specific
Two kinds here. One is /var/run/docker.sock mounts — tools that operate Docker itself, Portainer-style, 18 in the first sweep. Apple's runtime has no Docker-compatible socket, so no compose edit can save these.
The other kind is the runtime model difference: places where Apple's container is architecturally unlike Docker, so compose patterns that work on Docker fall over. These are fixable by changing what the compose says, and they collapse into just two patterns:
- Pattern 1: a data directory on a named volume, where Postgres's initdb refuses to initialize because the directory "isn't empty" (19 of 156 affected)
- Pattern 2: a bind-mounted data directory that MySQL/Postgres can't
chown, so the entrypoint dies immediately (31 of 156 affected; 18 overlap with pattern 1)
32 projects in total, a third of all failures. Both are perfectly ordinary compose idioms on Docker — that's the point. The behavior below is as of container 1.1.0.
Pattern 1: named volumes are mount points
A Docker named volume is just a directory. An Apple container named volume is a whole filesystem, mounted. ext4 creates a lost+found recovery directory (where fsck puts rescued files) at the root of every filesystem it makes — the one you see under / or /home on any Linux box. So the volume is non-empty from the moment it's mounted.
Docker: volume = empty directory
Apple container: volume = ext4 mount point (lost+found included)
Postgres's initdb refuses a non-empty data directory. Which means -v pgdata:/var/lib/postgresql/data — a line written in thousands of compose files — doesn't work as-is. The fix is one env var: point PGDATA one level below the mount point.
Pattern 2: bind mounts can't be chowned
Apple's runtime gives every container its own lightweight VM, and bind mounts reach into that VM via virtiofs — a protocol that exposes a host directory to a VM as a shared filesystem. The files are only visible through the share; their owner stays the host. So chown from inside the container fails. MySQL and Postgres images chown their data directory at the top of the entrypoint. Result: instant death by Operation not permitted.
Self-hosting compose files love bind-mounting data to host paths like /mnt/docker-volumes/<app>/, which is why this pattern hits so often. The fix: put the data directory (just that) on a named volume — those chown fine.
If the fix is deterministic, a machine can apply it
Both patterns have a mechanical diagnosis and a single known fix. So instead of making humans read the caveats section, I taught opossum to do it. In migration mode (up --from-docker-compose) it detects the known patterns and writes only the fixes into a separate file, compose.opossum.yaml, layered on top of your compose at startup. Two design rules:
- The original
docker-compose.ymlis never touched. docker compose doesn't know this filename and ignores it, so the same directory keeps working with both tools - Every generated fix carries a comment explaining what changed, why, how to verify it, and how to undo it. The intended reader is not just you but also whatever AI agent is working on your project — the file explains itself without context
So how many did it save?
Same 156 projects, same binary, arm A (no fix) vs arm B (auto-fix):
| Class | A: no fix | B: auto-fix | Δ |
|---|---|---|---|
| FULL-UP | 61 | 78 | +17 |
| PARTIAL | 21 | 7 | −14 |
| CRASH | 9 | 5 | −4 |
| STARTED-NONERUN | 40 | 40 | 0 |
| PREFLIGHT | 23 | 23 | 0 |
| TIMEOUT | 2 | 3 | +1 |
The full per-project ledger (one row per project: A class, B class, which fixes were written) and the sweep harness are published as a gist. Every number from here on can be recounted from that file.
A summary table can hide regressions that happen to be offset by improvements, so check the ledger row by row: nothing got worse. All 61 projects that were FULL-UP in A stayed FULL-UP in B (zero collateral damage from the auto-fix). What moved: 16 PARTIAL and 1 CRASH graduated to FULL-UP, 2 CRASH became PARTIAL, 1 CRASH became TIMEOUT — 20 changes, all in the improving direction. Everything else kept its class. bitwarden (passwords), paperless-ngx (document scanning), seafile (file sync), matomo (analytics), sonarqube (code quality) — apps that all carry a Postgres or MySQL behind them, which is exactly why they were tripping over the two data-directory patterns.
Where the 32 generated fix files went: 17 graduated to FULL-UP (8 from pattern 2 alone, 8 from both patterns, 1 from pattern 1 alone). The other 15 got a correct fix but stayed down behind a different wall — port collisions, docker.sock — one project can fail for several reasons at once, and now there's a number on that.
The timeout misclassified one project
TIMEOUT went 2 → 3. I looked at the new one: the auto-fix got it past its CRASH, which meant initialization finally ran to completion — in 154 seconds, past the 120-second cutoff, with every service up. Classified TIMEOUT, actually a success.
Count it and you get 79 (51%). But arm A has two TIMEOUTs of its own that I did not audit the same way, and reclassifying only the borderline cases that flatter the result isn't fair. So this article's number stays what the table says: 78.
78 of 156 — 39% to 50%. Half of a real-world compose corpus reaches full startup without you editing a single line.
One measurement lesson for the road: a hard timeout is necessary if you don't want a sweep to hang forever, and it will still misclassify the borderline. Read every guarded measurement as a floor. Both 39% and 50% here are floors in that sense.
What I didn't do / can't claim
- One machine, one pass per arm. The 23 PREFLIGHTs include this Mac's own port situation; some would pass elsewhere
- Unfilled secrets is not how anyone actually runs these stacks. Filling them would convert much of PARTIAL to FULL-UP; I didn't measure that
- The sweep runs below any
restartpolicy (143 of the 159 files declare one). Docker compose would absorb some first-boot crashes — a DB winning a startup race on retry — viarestart: always, so part of PARTIAL/CRASH may be my measuring layer's behavior rather than runtime incompatibility. Not yet isolated - No docker compose control run. "These would need the same adjustments on Docker" is inferred from the failure logs, not measured
- The pattern-2 fix moves where your data lives (bind mount → named volume). Harmless for fresh stacks; migrating existing data is out of scope
- docker.sock, kernel modules (WireGuard), cgroup-reading JVMs (old Elasticsearch): not fixable by editing compose, still broken
What counting taught me
With 29 official samples I thought about compatibility as a table: this field supported, that field not. What 156 real-world files taught me is that the real picture is a distribution — what fraction just works, and where the failures cluster. Measured that way: 40% works untouched, most failures are configuration rather than the runtime, the runtime-specific failures collapse into a couple of mechanically fixable patterns, and the unfixable remainder is the part that depends on Docker-the-infrastructure — which stays broken until Apple ships a compatible socket or volume seeding.
Apple's container compatibility story has moved from "not yet" to "fails predictably". Predictable failures are automatable fixes. And the 156-project ledger points at exactly which gap should close next.
If you run your own compose files through this, I'd love to hear how they land — especially the ones that break in ways not on this list.
Top comments (0)