Choosing and Tuning a Local Container Runtime
Every Compose file on this site assumes "a Docker engine", but on macOS and Windows that engine runs inside a Linux virtual machine, and the choice of VM manager decides how fast bind mounts are, how much memory the laptop loses at idle, whether the company owes a licence fee, and which obscure networking bugs the team will hit. Teams often inherit whatever the first engineer installed and then spend months debugging symptoms — slow hot reload, containers killed with exit code 137, a full disk — that are really runtime configuration. This topic, part of containerized local environments with Docker Compose, treats the runtime as a deliberate, documented part of the environment baseline.
The runtimes covered here all expose the Docker API, so the Compose files themselves do not change. What changes is the virtual machine underneath: its resource limits, its file-sharing mechanism, its disk image, and the socket path tools use to find it. Standardising those four things removes a whole class of "works on my machine" reports.
Prerequisites
- A current OS: macOS 13+ (needed for Apple's Virtualization.framework and virtiofs), Windows 11 or Windows 10 22H2 with WSL2, or any Linux distribution with a kernel from the last three years.
- Admin rights to install a VM manager, or an IT-approved package in the company's software portal.
- The Docker CLI and Compose plugin installed independently of Docker Desktop when evaluating alternatives:
brew install docker docker-compose docker-buildxon macOS. The CLI talks to whichever socketDOCKER_HOSTor the currentdocker contextpoints at. - An inventory of what the stack needs: total memory of all services at steady state, whether any image is amd64-only, and whether anyone uses the Kubernetes toggle in Docker Desktop.
A one-shot inventory command gives a baseline to compare runtimes against:
#!/usr/bin/env bash
set -euo pipefail
docker context show
docker info --format 'CPUs={{.NCPU}} Mem={{.MemTotal}} Driver={{.Driver}} OS={{.OperatingSystem}} Arch={{.Architecture}}'
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}'
docker system df
Comparing the runtimes on the criteria that matter
The decision is rarely about raw performance alone. For most teams, four criteria dominate: licensing (Docker Desktop requires a paid subscription for companies above 250 employees or USD 10 million revenue), bind-mount speed (which decides hot-reload latency), idle resource cost, and how much the runtime differs from Docker Desktop in edge cases such as host.docker.internal, socket paths and Kubernetes.
Colima is a thin CLI around Lima VMs; it is open source, scriptable, and close enough to Docker Desktop that most stacks move over by changing the socket. OrbStack is a commercial macOS-only app with the fastest file sharing and lowest idle cost measured by most teams. Podman is daemonless and rootless by design, which is attractive for security but means Compose runs through a compatibility socket with a few behavioural differences. The three-way macOS comparison has measurements; the Colima migration guide and the Podman guide cover switching.
- Record the current runtime and version for every developer in the onboarding survey or health check.
- Shortlist runtimes that satisfy licensing first; performance comparisons are irrelevant if the tool cannot be used.
- Trial the shortlist on the slowest supported laptop with the real stack, not a hello-world container.
A useful trial measures four numbers per runtime: cold docker compose up time from an empty image cache, time from saving a source file to the dev server reporting a rebuild, idle host memory with the stack running and the IDE open, and the size of the VM disk after a full build. Record them in a small table in the repository so the decision is reproducible and can be revisited when a runtime releases a major version. Numbers from a vendor blog post or a colleague's newer laptop are not a substitute: file-sharing performance in particular depends heavily on the number of files in the project and the file watcher the framework uses.
Treat edge-case compatibility as a first-class criterion as well. Before committing, run the stack's integration tests, a multi-architecture build, anything that uses host.docker.internal, and any tool that talks to the Docker socket directly — Testcontainers, Tilt, the VS Code Dev Containers extension. These are where runtimes quietly diverge, and discovering a gap after the whole team has migrated is far more expensive than finding it during the trial.
Sizing CPU and memory for the VM
The Linux VM gets a fixed slice of the host's memory. Too small and the kernel's out-of-memory killer terminates containers with exit code 137 — usually the database or the JVM, since they are the largest. Too large and the host swaps, the IDE stutters, and the laptop fan runs constantly. The right number is the stack's steady-state usage plus headroom for builds, which are the peak.
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d
sleep 60
docker stats --no-stream --format '{{.MemUsage}}' \
| awk -F'/' '{ v=$1; if (v ~ /GiB/) { sub(/GiB/,"",v); s+=v*1024 } else { sub(/MiB/,"",v); s+=v } } END { printf "steady state: %.0f MiB\n", s }'
A reasonable rule: steady state × 1.5, plus 2 GiB for BuildKit during builds, capped at half the host's physical memory. On a 16 GiB laptop running a stack that idles at 3 GiB, that gives 6.5 GiB — round to 6 or 7. The Docker Desktop tuning guide shows where each runtime stores the setting and how to pin it in a checked-in file.
Per-service limits complement the VM limit. Without them, one runaway process takes all the VM's memory and the OOM killer chooses the victim. With them, the misbehaving service dies alone and its logs say why. JVM-based services deserve special attention: without an explicit -Xmx, a JVM sizes its heap from the memory it can see, which inside a container limit is the limit itself, and a heap sized to the full limit leaves no room for metaspace, threads and native buffers. Setting the heap to roughly half the container limit, as below, is a conservative default that avoids silent OOM kills during load:
services:
search:
image: opensearchproject/opensearch:2.15.0
environment:
OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m
deploy:
resources:
limits:
memory: 1g
cpus: "1.5"
File sharing and bind-mount performance
Bind mounts cross the VM boundary, and that crossing is the single biggest source of performance difference between runtimes. Modern runtimes on macOS use virtiofs, which is several times faster than the older gRPC-FUSE and osxfs implementations; Colima defaults to virtiofs only when started with --vm-type vz. The difference shows up as hot-reload delay and as npm install or bundle install times inside a bind-mounted directory.
#!/usr/bin/env bash
set -euo pipefail
docker run --rm -v "$PWD":/src -w /src alpine sh -c '
start=$(date +%s)
for i in $(seq 1 2000); do echo x > /src/.bench-$i; done
rm -f /src/.bench-*
echo "2000 small writes: $(( $(date +%s) - start ))s"'
Run this benchmark on each candidate runtime from the project directory. Anything above a few seconds means dependency directories such as node_modules should live in a named volume rather than on the bind mount, as described in choosing between bind mounts and named volumes.
File-change notification is the second half of the story. Hot reload depends on inotify events reaching the container when a file changes on the host. virtiofs and OrbStack's file system deliver them reliably; the older sharing modes deliver them late, drop them under load, or not at all, which forces frameworks into polling mode with its own CPU cost. When a developer reports that hot reload "sometimes" works, check the mount type before touching the framework configuration — the symptom is intermittent precisely because events are being dropped, and the hot-reload troubleshooting guide covers the polling fallback for runtimes that cannot deliver them.
Disk growth and cleanup
The VM's disk is a sparse image file on the host — Docker.raw for Docker Desktop, a Lima disk for Colima — that grows as images, build cache and volumes accumulate, and rarely shrinks on its own. A developer who has not pruned in six months commonly has 60–100 GB tied up, and the first sign is a failed build with no space left on device even though the host disk looks fine.
#!/usr/bin/env bash
set -euo pipefail
docker system df -v | sed -n '1,12p'
docker builder du | tail -1
The breakdown matters more than the total. Build cache is the usual majority on a machine that builds images locally, and it is entirely disposable. Images are the next largest; most are re-pullable, but locally built tags that nothing references any more are pure waste. Volumes are usually small in count but can be large individually — a Postgres volume with a restored production-sized dump, or an Elasticsearch data directory — and they are the one category that holds state a developer may care about. Reading docker system df -v before deleting anything tells you which of the three is actually responsible, and avoids the reflex of running docker system prune -a --volumes, which fixes the disk and deletes everyone's local database at the same time.
A weekly prune of build cache older than a week and dangling images is safe for almost every team, because anything still needed is rebuilt from cache or pulled again. Volumes are the exception: they contain database state, so prune them only on purpose. The disk reclamation guide covers shrinking the VM image itself after pruning.
#!/usr/bin/env bash
set -euo pipefail
docker builder prune --filter until=168h --force
docker image prune --force
docker container prune --force
Pinning the runtime in the repository
A runtime choice that lives only in a wiki page drifts. Put it in the repository: a checked-in config for the VM size, a doctor check that reports the active context and mount type, and a bootstrap step that creates the VM with the team's settings. A plain runtime.env file is enough, and every script that needs the numbers sources it:
#!/usr/bin/env bash
set -euo pipefail
cat > runtime.env <<'EOF'
VM_CPUS=4
VM_MEMORY_GIB=6
VM_DISK_GIB=80
EOF
. ./runtime.env
if command -v colima >/dev/null && ! colima status >/dev/null 2>&1; then
colima start --cpu "$VM_CPUS" --memory "$VM_MEMORY_GIB" --disk "$VM_DISK_GIB" --vm-type vz --mount-type virtiofs
fi
The doctor side reads the same file and compares it with what the engine reports, so a developer who shrank the VM to save memory for a video call and forgot to restore it gets a precise message instead of mysterious exit-137 failures a day later:
#!/usr/bin/env bash
set -euo pipefail
ctx="$(docker context show)"
mem_gib=$(( $(docker info --format '{{.MemTotal}}') / 1024 / 1024 / 1024 ))
echo "context=$ctx memory=${mem_gib}GiB"
if [ "$mem_gib" -lt 6 ]; then
echo "VM has ${mem_gib}GiB; the stack needs at least 6GiB. See the runtime tuning guide."; exit 1
fi
This check belongs in the same script as the rest of the onboarding health check, so an under-provisioned VM is reported with the fix before the first docker compose up fails.
Platform caveats
macOS (Docker Desktop): changing memory or CPU in Settings restarts the VM and stops every container. Schedule changes, and never script them in a way that runs during a build.
WSL2: Docker Desktop on Windows uses the WSL2 VM, whose limits are set in
%UserProfile%\.wslconfig(memory=,processors=), not in Docker Desktop's settings. Runwsl --shutdownafter editing it.
Apple Silicon (ARM64): amd64 images run under Rosetta or QEMU emulation in every runtime. Rosetta is faster and is enabled per runtime —
colima start --vz-rosetta, a checkbox in Docker Desktop, on by default in OrbStack.
Linux: Docker Engine runs natively with no VM, so memory is shared with the host and bind mounts are native. The runtime question on Linux is mostly rootful versus rootless, which affects file ownership on bind mounts.
Rollback and recovery
Switching runtimes is reversible because images and volumes are just data in each runtime's VM. Keep the previous runtime installed until the new one has run the full stack for a week. To switch back, change the context — no Compose changes are needed:
#!/usr/bin/env bash
set -euo pipefail
docker context ls
docker context use desktop-linux
docker compose up -d
Volumes do not move between runtimes automatically. Before switching, dump databases with their native tools (pg_dump, mysqldump) rather than copying volume directories, and restore into the new runtime; the database seeding topic has scripts for that.
Frequently Asked Questions
Do our Compose files need to change when we switch runtimes?
Usually not. Docker Desktop, Colima and OrbStack all run the real Docker engine, so Compose files behave the same. Podman is the exception: it runs Compose through a Docker-compatible socket, and a few features such as some network_mode values and build secrets behave differently.
How much memory should the VM get?
Measure the stack's steady-state memory with docker stats, multiply by 1.5, add about 2 GiB for builds, and cap the total at half of physical RAM. Then add per-service memory limits so a single runaway service is killed instead of the database.
Why do containers exit with code 137?
Exit code 137 means the process received SIGKILL, almost always from the kernel's out-of-memory killer inside the VM. Check docker inspect --format '{{.State.OOMKilled}}'; if it is true, raise the VM memory or the service's memory limit.
Can different developers use different runtimes?
Yes, as long as the doctor check verifies the properties the stack depends on — memory, mount type, architecture support — rather than the runtime's name. Standardise on one for onboarding documentation and support the others on a best-effort basis.
Related
- Move the team off Docker Desktop without breaking the stack
- Right-size VM memory and CPU
- Speed up node_modules on macOS bind mounts
- Run amd64 images on Apple Silicon
Every guide in this topic
- Migrating a Team From Docker Desktop to ColimaMove macOS developers from Docker Desktop to Colima without breaking Compose: vz and virtiofs settings, socket paths, host.docker.internal, buildx and volume data.
- OrbStack vs Colima vs Docker Desktop on Apple SiliconCompare OrbStack, Colima and Docker Desktop on M-series Macs for a team stack: bind-mount speed, idle memory, amd64 emulation, licensing and support effort.
- Reclaiming Disk Space From Docker on LaptopsFix no space left on device during builds: find what docker system df is holding, prune build cache safely, keep volumes, and shrink Docker.raw or ext4.vhdx.
- Running Compose Projects With PodmanRun an existing compose.yaml on Podman: the Docker-compatible socket, rootless port and volume ownership quirks, short image names and depends_on health conditions.
- Tuning Docker Desktop Memory and CPU LimitsStop exit code 137 OOM kills and a sluggish laptop by sizing Docker Desktop's VM from measured usage, with per-service limits and a checked-in settings baseline.