container compose brings Docker Compose to Apple's container
CLI on macOS. It is an external plugin: once installed, container compose up
works the way docker compose up does, with the real Docker Engine and
Docker Compose CLI running inside one persistent container machine Linux VM.
cd ~/projects/my-app
container compose up -d
container compose psThe plugin does not translate Compose files into Apple Container operations and does not replace or patch any installed Apple service. It is a thin, careful client of the host's public machine and process APIs.
- Unmodified Docker Compose. Every Compose subcommand, flag, and file format
works because the real
docker composeruns the project inside the machine. - One persistent machine. All projects share a machine named
compose. Images, named volumes, networks, and build cache survivecompose downand machine shutdowns. - Your home directory, same path. The machine mounts your macOS home directory read-write at the same path, so relative and absolute bind mounts under it work unchanged.
- Published ports on
localhost. TCP ports a container publishes are mirrored onto127.0.0.1and::1, as Docker Desktop does. - A Docker socket for host tools.
container compose --socket-pathgives thedockerCLI, IDEs, and Testcontainers aDOCKER_HOSTthat talks to the machine's daemon. - Native
--helpand shell completions. Forwarded Compose help reads ascontainer compose, and bash, zsh, and fish completions are generated without booting a VM. - Optional idle shutdown. The machine can power itself off after Docker has been idle, and boots again on the next command.
- Safe by design. The plugin records its own ownership of the machine, refuses to adopt machines it did not create, and never deletes machine data on install, upgrade, or removal.
- macOS 15 or newer on Apple silicon.
- Swift 6.2 or newer, only to build the plugin from source.
- An installed and running
containerhost (container system start). The plugin pins the host client SDK it is built against inPackage.swift; that pin is a compatibility target, not a guarantee that every released host version exposes the same machine APIs. - A normal login user. Build and operate the plugin as yourself, not as root. The installer asks for elevated privileges only when the installation prefix is protected.
Pick the route that matches how you installed container. Each one ends
with the same layout in the host's plugin directory, which the host's
external plugin loader discovers on its own:
<install-root>/libexec/container-plugins/compose/
├── config.toml
└── bin/compose
No daemon is registered and no host service is replaced. The install root is
wherever container itself lives, which is why the route depends on how it
got there.
brew install RobertDeRose/tap/container-compose-plugin
container system start
compose --helpHomebrew installs container automatically if it is missing and provides the
shorter compose command. On first use, compose registers the plugin with
Homebrew's host, then forwards arguments to container compose. No separate
registration command or sudo is needed. For example, compose up -d is the
same as container compose up -d.
The wrapper also restores registration on the next compose invocation after
brew upgrade container. Both host and plugin paths use Homebrew's stable
opt links. After registration, container compose remains available directly.
mise installs container into a versioned directory of its own, so the
plugin goes there too, with no elevated privileges:
mise use -g container 'github:RobertDeRose/container-compose-plugin[bin_path=cli,strip_components=1]'
container system start
compose --helpThe bin_path and strip_components options put the release archive's
cli/compose wrapper on your PATH, rather than the native plugin executable.
With mise activated in your shell, compose forwards to the selected mise
container, registering the plugin on first use and again whenever either
tool changes version. For example, compose up -d is the same as
container compose up -d.
To keep container compose registered after mise upgrade without running
compose first, give each tool a postinstall step in your mise config:
# ~/.config/mise/config.toml
[tools.container]
version = "latest"
# Reinstalls the plugin into a newly installed container, if it is present.
postinstall = """
if plugin=$(mise where github:RobertDeRose/container-compose-plugin 2>/dev/null); then
"$plugin/install.sh" --replace --prefix "$MISE_TOOL_INSTALL_PATH/Payload"
fi
"""
[tools."github:RobertDeRose/container-compose-plugin"]
version = "latest"
depends = ["container"]
strip_components = 1
bin_path = "cli"
postinstall = """
"$MISE_TOOL_INSTALL_PATH/install.sh" --replace --prefix "$(mise where container)/Payload"
"""Apple's package installs under /usr/local, so the plugin directory is
protected and the installer asks for elevated privileges. Every
GitHub release
carries container-compose-plugin-<version>-darwin-arm64.tar.gz and its
SHA-256 checksum. The archive holds the plugin and its installer:
gh release download --repo RobertDeRose/container-compose-plugin --pattern '*-darwin-arm64.tar.gz*'
shasum -a 256 -c container-compose-plugin-*-darwin-arm64.tar.gz.sha256
tar -xzf container-compose-plugin-*-darwin-arm64.tar.gz
container system start
./compose/install.sh
container compose --helpWithout the GitHub CLI, fetch both files from the release page with
curl -LO. Release builds are ad-hoc signed, not notarized, so an archive
saved by a browser carries the quarantine attribute and macOS refuses to run
the executable in it. Download with gh or curl, or clear the attribute
with xattr -dr com.apple.quarantine compose before installing.
The installer asks the running host for its install root. To name the root yourself, or when the host is not running:
./compose/install.sh --prefix /usr/localgit clone https://github.com/RobertDeRose/container-compose-plugin.git
cd container-compose-plugin
container system start
make install
container compose --helpmake install builds the compose executable in release mode, stages it with
its plugin manifest and licenses, and installs that layout the same way the
release installer does. make install PREFIX=/usr/local names the root.
Do not assume /usr/local. The default reads the running host's reported root
through compose --install-path, and a versioned prefix changes after a host
upgrade, so reinstall after one; the Homebrew compose wrapper and mise hooks
keep their installations registered as described above.
Homebrew and mise upgrade the plugin like any other tool. Otherwise, install again: the installer upgrades this plugin's existing installation in place, and refuses to overwrite any other plugin at that path.
./compose/install.sh # from a newer release archive
make install # from source, after git pullUpgrading replaces only the plugin files. The machine, its Docker data, and
the plugin's state are untouched. To uninstall, stop the background forwarder,
then remove the directory that container compose --install-path prints, or
brew uninstall container-compose-plugin. Removing the plugin does not
delete the machine. See operations and recovery for the
full procedure.
The completion scripts chain to any completion already registered for
container, so load them after the host's own completion, for example at the
end of your shell profile:
# ~/.zshrc
eval "$(container compose --completions zsh)"bash and fish are supported the same way.
Run Compose from a project under your macOS home directory:
cd ~/projects/my-app
container compose up -d
container compose logs -f
container compose exec api sh
container compose downThe first invocation does more than later ones. The host pulls the guest image
from GHCR, creates the compose machine with the default CPU and memory, boots
it, waits for Docker, and then runs your command. Later commands reuse the
running machine, and a stopped machine boots automatically.
A few rules apply to every command:
- Projects must live under your home directory. Bind sources outside it are not available in the machine.
- All projects share one machine, so use distinct Compose project names where two projects would otherwise collide.
- Your environment variables are forwarded for Compose interpolation. Host Docker connection settings, credential helpers, and SSH agent forwarding are deliberately not reused.
- After the subcommand, arguments are opaque.
container compose --helpshows plugin help,container compose up --helpruns Compose help inside the machine, and a guest program's own--helpis passed straight through.
compose down stops containers but leaves the VM running. The native machine
commands manage the VM itself:
container machine ls
container machine inspect compose
container machine stop compose
container compose ps # boots the stopped machine againThe machine is created with default resources. To change them, use the native command and restart the machine; the change applies to every project on the next boot:
container machine set -n compose cpus=8 memory=8gb
container machine stop compose
container compose psNative machine deletion is destructive: it removes all Docker data, including named volumes. Never use it as an install or upgrade step.
The plugin reads its section from the host's existing configuration files. The
app-root config/config.toml and install-root etc/container/config.toml are
both consulted through the host's own loader, including its environment
variable support. container system status --format json reports their
paths.
[plugin.compose]
cpus = 4
memory = "4gb"
idle-shutdown-seconds = 600| Key | Default | Meaning |
|---|---|---|
cpus |
4 |
CPUs for a machine the plugin creates. An existing machine keeps its own setting. |
memory |
"4gb" |
Memory for a machine the plugin creates. At least 1 GB. |
idle-shutdown-seconds |
0 |
Power the machine off after Docker has been idle this long. 0 disables it. |
A positive idle timeout takes effect on the next Compose invocation. Running containers, connected Docker API clients, and failed activity probes keep the machine up or reset the timer. The watcher samples the timeout, so it is a best-effort policy rather than an exact deadline, and a running but idle application container counts as activity.
The guest image can be overridden with CONTAINER_COMPOSE_MACHINE_IMAGE for a
machine that has not been created yet, for example to test a local build:
container build --file machine-image/Containerfile --tag compose-machine:dev machine-image
export CONTAINER_COMPOSE_MACHINE_IMAGE=compose-machine:devChanging the image does not rebuild an existing machine.
While the plugin's background forwarder is running, TCP ports that containers
publish are also reachable on macOS localhost, both 127.0.0.1 and ::1.
container compose starts the forwarder, and so does --socket-path.
Forwarding begins within a few seconds of a container starting and stops when
the port disappears or the machine stops.
- Ports are mirrored only on loopback, never onto your LAN. For ports below 1024, which an unprivileged user can bind only on all interfaces, the forwarder binds the wildcard address and rejects non-local peers.
- A port that another program on the Mac already holds is skipped so that
localhosttraffic never splits between two programs. AirPlay Receiver's5000and7000are common examples. The skip is logged once and retried every 30 seconds. - UDP is not forwarded.
- Discovery reads the guest's
docker-proxyprocesses, so it requires the Docker userland proxy, which is enabled by default.
Published ports stay reachable through the machine's address as well. The plugin neither installs DNS configuration nor adds wildcard project aliases.
Host Docker clients can use the machine's daemon through a Unix socket in your home directory:
# ~/.zshrc
export DOCKER_HOST="$(container compose --socket-path)"--socket-path prints unix://$HOME/.local/run/docker.socket. If no
forwarder from the installed plugin is listening there, it starts one in the
background. It does not contact the container services or boot the machine, so
it is cheap enough for a shell profile. A forwarder started by an older plugin
build is replaced, which is how upgrades take effect.
The machine boots on the first Docker request, so idle shutdown still applies.
If the machine cannot be prepared, clients receive a Docker API error that says
why. The socket is mode 0600 and no TCP listener is added. Paths sent by
host clients resolve inside the machine, so bind mounts work for paths under
your home directory.
The forwarder logs to ~/Library/Logs/container-compose/docker-socket.log.
When replaced or sent SIGTERM it releases the socket at once and exits after
open connections finish, so a running logs -f is not cut off. To stop it:
kill "$(head -1 ~/.local/run/docker.socket.lock)"For direct Docker administration inside the guest:
container machine run -n compose --root env \
DOCKER_HOST=unix:///etc/docker/docker.sock DOCKER_CONFIG=/root/.docker \
docker infocontainerfinds thecomposeexecutable in its external plugin directory and runs it with the remaining arguments.- The plugin resolves the working directory and environment to forward, failing fast if the directory is outside the home mount. Nothing has touched the machine yet.
- It starts the background forwarder if one is not already running.
- Under a per-plugin initialization lock it inspects the
composemachine. On first use it creates one from the pinned guest image; otherwise it verifies that the machine's immutable configuration and guest ownership token match its own record and refuses anything ambiguous. - It boots the machine if needed, waits for its address and for Docker, and applies the configured idle timeout.
- It releases the lock and runs
docker composeas a guest process, relaying stdin, stdout, stderr, terminal size, and signals. The lock covers initialization only, so independent projects run concurrently.
| Path | Role |
|---|---|
Sources/ComposePlugin |
The compose executable's entry point. Handles --help and --version, then hands off to the command. |
Sources/ContainerCompose |
The plugin library: the CLI command, configuration, environment policy, machine orchestration and ownership, the guest process runner, help rewriting, completions, and the socket and port forwarder. |
Sources/ContainerCompose/Compatibility |
A small client layer over the host's existing process routes. It carries descriptor ownership, cancellable reply waits, and bounded guest process termination locally so no patched host is required. |
Sources/ComposeSupport |
Portable pieces with no Apple dependencies: the ownership record store and cancellable callback waits. These build and test on Linux too. |
machine-image |
The guest image. Debian with pinned Docker Engine, Compose, and Buildx packages, plus the systemd idle-shutdown service. |
scripts |
Staging, installation, packaging, and the installer's own test. |
Tests/ComposePluginTests |
macOS tests for the plugin library, including the forwarders and process lifecycle. |
Tests/ComposeSupportTests |
Portable tests, including the real idle-shutdown script run against fake commands. |
docs |
Architecture, operations, and testing references. |
Inside the plugin library, the main pieces are:
- ComposeCommand parses plugin options (
--completions,--install-path,--socket-path) and captures everything else for Compose. - ComposeEnvironment decides which host paths and variables are forwarded.
- ComposeMachineManager, ComposeMachineOwnership, and ComposeMachineInitializationLock create, verify, boot, and serialize access to the shared machine.
- ComposeProcessRunner runs the forwarded command through the compatibility client.
- ComposeForwarderService is the background process behind
--socket-path. It runs ComposeSocketForwarder (the Docker socket) and ComposePortForwarder (published ports) as peers, both bridging each connection through a guest process with ComposeGuestBridge. ComposeGuestPortSource watchesdocker-proxyprocesses for published ports with one long-lived guest process per boot.
The plugin keeps an ownership record at
<app-root>/plugin-state/compose/machine.json holding the machine ID, image
digest, user identity, and a random UUID. The same UUID is written inside the
guest at /etc/container/compose-plugin-owner. Before boot it checks the
machine's immutable configuration; before forwarding commands or changing the
idle policy it checks the guest token. Runtime state such as running status,
addresses, and process lists is never duplicated. The native machine service
owns it.
A machine that exists without a record, a record without a machine, a corrupt record, or a mismatched token is an error. The plugin never adopts, repairs, or deletes a machine on its own. Without an upstream creation-policy option, the first machine on an empty host may become the host's default machine; the plugin reports that rather than editing daemon state.
The image is built from machine-image/ and published for linux/arm64 to
ghcr.io/robertderose/container-compose-machine. The plugin pins its default
by digest in ComposeConfiguration.defaultImage, and the host pulls it when
the machine is first created. The Machine image GitHub Actions workflow
publishes on pushes to main that change machine-image/ (tagged edge), on
v* tags (tagged with the version), and manually for any branch. Every image
is also tagged sha-<commit>, and the run summary prints the digest to pin.
- Architecture: how a command runs, machine ownership, the guest process client, and the forwarder designs.
- Operations and recovery: state locations, what each refusal means, upgrading, and removal.
- Testing: the test tiers, what they cover, and the checklist to run on a real host.
make build # release build of the compose executable
make test # swift test plus the shell script tests
make lint # swift-format and shell syntax checks
make stage # build and stage dist/staging/compose for install or package
make package # architecture-specific tar.gz with checksumCI runs make test on macOS and Linux. Linux builds only the portable
ComposeSupport target and its tests; the executable and Apple client
libraries require macOS. Local builds are ad-hoc signed; set
CODE_SIGN_IDENTITY to sign with a certificate.
Swift comes from Xcode. mise install provides the remaining tools pinned in
mise.toml: Cocogitto for releases.
mise install also provides the linters that hk runs
from hk.pkl, and installs Git hooks that fix staged files and check commit
messages with Cocogitto. mise run check runs
every linter over the repository, as CI does; mise run fix applies their
fixes.
site/ holds the project's product page: static HTML, CSS, and JavaScript
with no build step. The Pages workflow publishes it to GitHub Pages on pushes
to main that change site/. Preview it locally with mise run site, which
serves it at http://localhost:8000; set PORT to use another port.
Every push to main runs the release workflow, which runs
scripts/release.sh. Cocogitto derives the next
version from the Conventional Commits since the last tag (configured in
cog.toml): fix, perf, and revert release a patch, feat a minor, and a
breaking change a major. Below 1.0 the breaking marker changes nothing:
feat! releases a minor, fix! a patch, and a breaking change of a type that
does not release, such as refactor!, releases nothing. Other commit types do
not release. The script stamps the version into the executable
with scripts/set-version.sh, builds and packages it on macOS, tags
v<version>, and publishes a GitHub release with the archive, its checksum,
and notes generated from the commits. Nothing is committed; the checked-in
version stays a development marker. Release builds are ad-hoc signed;
notarization is not configured. mise run release:preview shows the version
and notes the next release would publish, without changing anything.
A release that stopped partway is finished by the next run. Before looking for
a new version, each run checks the latest tag: if it has no GitHub release, or
only a draft from an interrupted upload, the run rebuilds that tag's commit and
publishes the release, then makes sure the Homebrew formula points at it,
using the checksum uploaded with the release. Nothing new is released until the
latest tag is finished. To retry without a new commit, run the release workflow
from the Actions tab on main. The preview reports a latest tag that is still
unpublished.
The release requires the Robborg GitHub App and the ROBBORG_CLIENT_ID
and ROBBORG_PRIVATE_KEY secrets. A tag pushed with the workflow
token would trigger nothing; the app's tag starts the machine image
workflow, which tags the guest image the released plugin pins with the same
version, so container-compose-machine:<version> is exactly what that
release installs. The same step renders homebrew/container-compose-plugin.rb.in
with the version and checksum and pushes it to the
RobertDeRose/homebrew-tap
repository. The app must be installed on both repositories, and the release
token is scoped to both. Dependency updates come from Dependabot.
See CONTRIBUTING.md for the conventions pull requests are expected to follow.
Apache-2.0. See LICENSE and NOTICE.md for the attributions of derived and third-party code. This project is not maintained or endorsed by Apple.