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.

What Sits Under docker compose on a Laptop Layered diagram from the Docker CLI down through the socket, the Linux VM and the host file sharing layer. What Sits Under docker compose on a Laptop docker compose CLI same on every runtime Docker API socket path differs per runtime Linux VM CPU, memory, disk image File sharing virtiofs, 9p or sshfs Host OS macOS, Windows, Linux
The CLI and Compose files are identical across runtimes; the lower three layers are where they differ.

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-buildx on macOS. The CLI talks to whichever socket DOCKER_HOST or the current docker context points 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.

Local Runtime Comparison Table comparing Docker Desktop, Colima, OrbStack and Podman on licence, file sharing, idle memory and Kubernetes support. Local Runtime Comparison Criterion Desktop Colima OrbStack Podman Licence paid at scale free, MIT paid at scale free, Apache Mount type virtiofs virtiofs custom fs virtiofs Idle memory high medium low medium Kubernetes built-in k3s flag built-in kind
All four expose the Docker API; the differences are licensing, mount speed and idle cost.

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.

  1. Record the current runtime and version for every developer in the onboarding survey or health check.
  2. Shortlist runtimes that satisfy licensing first; performance comparisons are irrelevant if the tool cannot be used.
  3. 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"
VM Memory Sizing for a 16 GiB Laptop Bar chart showing steady-state stack memory, build headroom, recommended VM size and the host remainder. VM Memory Sizing for a 16 GiB Laptop stack steady state 3 GiB plus 50% headroom 4.5 GiB plus build peak 6.5 GiB left for host 9.5 GiB
Sizing from measured steady state keeps both the containers and the IDE out of swap.

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.

virtiofs vs Legacy File Sharing Comparison of virtiofs against older gRPC-FUSE and sshfs sharing on macOS. virtiofs vs Legacy File Sharing gRPC-FUSE / sshfs virtiofs slow metadata calls near-native metadata inotify events unreliable events delivered npm install minutes npm install seconds default on old setups default on vz VMs
The mount type is a runtime setting; checking it is part of the drift diagnostic.

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.

Runtime Baseline Kept in the Repo Flow from a checked-in VM config through bootstrap creation to the doctor check that detects drift. Runtime Baseline Kept in the Repo runtime.env CPU, memory, disk make bootstrap creates the VM docker context points CLI at it make doctor flags drift
The same numbers drive VM creation and the drift check, so they cannot disagree.

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. Run wsl --shutdown after 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.

Every guide in this topic