Compose,
for Apple Containers
Bring a stack up
container compose up
Install the plugin
git clone https://github.com/andrew-waters/compose
cd compose
make build && sudo make install
If you are subscribed to #230, #1846 or #239, you have been waiting for the runtime to grow a compose command. It is not going to. Compose is out of scope upstream, and plugins are the route container’s maintainers pointed at instead.
So this is a plugin. It installs into container’s plugin directory, which makes
container compose up the real command under the real CLI. Not a second binary with its own name,
its own flags and its own idea of what a project is.
Coming from Docker Compose
| Capability | Docker Compose | container compose |
|---|---|---|
| Carries over unchanged | ||
image, build |
yes | yes args, target and dockerfile included; the build
itself shells out to container build |
environment, env_file, .env |
yes | yes interpolation included, with a line and column on every error |
command, working_dir, labels, container_name |
yes | yes |
ports |
yes | yes short and long form; a clash with a running container stops the plan before anything is created |
Bind mounts under volumes |
yes | yes |
deploy.resources |
yes | yes a VM cannot have half a core, so cpus round up |
depends_on, list form |
yes | yes dependencies start first |
dns, dns_search, dns_opt |
yes | yes |
Project naming, -f, -p, --dry-run |
yes | yes name from the file, then the directory |
| Works differently here | ||
| Reaching a service by name | yes db |
partial hostnames are unique per machine, so it is
myapp-db rather than db
(#1809) |
depends_on conditions |
yes service_healthy |
not yet order only; waiting for ready needs health in the runtime (#1502) |
healthcheck |
yes | not yet a container cannot report health, so probes would have to run here |
| Named volumes | yes | not yet bind mounts today |
| Several networks per service | yes | not yet one attachment per container |
entrypoint |
yes | not yet the create surface overrides cmd, not entrypoint |
profiles, include |
yes | not yet |
pull_policy, platform |
yes | partial an image is pulled when it is missing, and everything on this stack is linux/arm64 |
ps, logs, exec, stop, start |
yes | not yet only up and down here, with
container’s own ls and logs covering the rest meanwhile |
| Not possible on this runtime | ||
restart |
yes | no nothing restarts a container that exits (#2142) |
user |
yes | no the create surface cannot set the process user |
cap_add, cap_drop, privileged |
yes | no capabilities are not settable and there is no privileged mode |
devices, tmpfs, ulimits |
yes | no no device passthrough, no tmpfs mounts, no settable limits |
secrets, configs |
yes | no container mounts neither |
extra_hosts |
yes | no the hosts file is not writable at create |
network_mode |
yes | no only user-defined networks are attachable |
Nothing in the last band is silently dropped. Each one refuses the file by name, on the line it sits on. The middle band is where a contribution lands today.
What you actually getwhen you install it
Not a wrapper that shells out to container run in a loop. A parser, a dependency graph and a
planner, with the runtime call at the very end.
Plugin, not binary
The real command
container compose up, container compose down. It lands in container’s
plugin directory and appears in container --help beside everything else. Your muscle
memory, your scripts and your CI all keep working the way they read.
No silent drops
It refuses what it cannot honour
Every compose key is classified: honoured, deferred, or impossible until the runtime grows a feature. Anything behavioural that would not happen stops the run, named, with the line it is on. Nothing is quietly ignored.
Idempotent
Containers are the only state
There is no state file to drift or delete. A second up compares each service against
the hash stamped on the container it produced, then creates, starts, leaves alone, recreates or
removes accordingly.
Testable
The planner is a pure function
A compose file plus a snapshot of what exists goes in; an ordered list of operations comes out. No subprocesses, no XPC, no daemon. Ordering and reconciliation are where compose tools go subtly wrong, and here they are covered by tests that start nothing.
Built to be embedded
A package, not just a binary
The model, the parser and the planner ship as a Swift package with no runtime client in them. A GUI can link it and execute plans over XPC directly, without shelling out to this plugin and without the plugin being installed at all.
One dependency
Yams, and nothing else
The libraries depend on the YAML parser apple/container already uses, so linking them
adds nothing to your graph. It reports a line and column for every node, which is what lets a
refusal point at the line it is refusing.
container compose up
myapp ./compose.yaml, 3 services
db create no container exists
api create no container exists
web create no container exists
[1/9] create network myapp
[2/9] pull postgres:17
[3/9] create container myapp-db
[4/9] start myapp-db
[5/9] build myapp-api from /Users/you/work/myapp/api
[6/9] create container myapp-api
[7/9] start myapp-api
[8/9] create container myapp-web
[9/9] start myapp-web
It would rather stopthan lie to you
container compose up
myapp ./compose.yaml, 4 services
error: ./compose.yaml asks for 2 things this cannot do
8:14: `restart` in service `db` is not honoured: container has no restart policy;
a container that exits stays exited
15:11: `user` in service `api` is not honoured: the create surface cannot set the
process user
error: nothing was created. Remove or change those keys and run again.
Keys that cost nothing to ignore, such as an obsolete version, are printed as
notes and stepped over. The difference is a severity carried per key, not a judgement made at the moment of
refusal.
Three things to knowbefore you install it
Nothing waits for ready
up starts dependencies before dependents, but a container cannot report health, so it
cannot wait for one to become ready. Until
#1502 lands, your app still needs to retry
its first connection.
A service answers to <project>-<service>
Hostnames have to be unique across every container on the machine, so db is reachable as
myapp-db, not db. Compose’s own naming needs per-network namespacing:
#1809.
Upgrading container wipes plugins
The installer clears the plugin directory on every upgrade (#1617), so expect to reinstall afterwards. Keep the two lines above to hand; an installer that asks for admin once is the fix, and it is not here yet.
Install it
A plugin is a directory with a config file and a binary. The plugin directory is
root owned, which is what the sudo is for.
Recommended
From source
Swift 6.2 is all it takes. Clone the repo, then build and install: the first stamps the version from the tag, the second puts the plugin where the CLI looks for it.
make build sudo make install
Prebuilt
From a release
The tarball is the plugin directory exactly as it installs, so unpacking it into place is the whole install. Not notarised yet, so macOS may quarantine it on the way in.
tar -xzf compose-*.tar.gz sudo cp -R compose \ /usr/local/libexec/container-plugins/
Then check
Confirm the CLI found it
A plugin the CLI cannot see fails quietly, with no error worth reading. One command settles it: the abstract and both subcommands should print.
container compose --help
Requires an Apple silicon Mac on macOS 26 and apple/container 1.4.1 or later.
One of these, not five
Everyone who wanted compose on this runtime went and built a piece of it. That is four or five parsers, four or five planners, and four or five sets of the same subtle ordering bugs, all against a runtime that is still moving underneath them.
This is the version built to be shared: a plugin for the CLI, a package for anyone’s GUI, and a coverage table that says plainly what is missing. If you have been following those threads, the most useful thing you can do now is bring your compose file, find the key it refuses, and file it.