Containers die with exited with code 137, Postgres restarts in a loop with server process was terminated by signal 9: Killed, or a webpack build inside a container stalls at 92% — while on the host, the IDE freezes and kernel_task pegs the CPU. Both symptoms come from the same setting: the fixed amount of memory and CPU given to Docker Desktop's Linux VM. This page shows how to size it from measurements rather than guesses and how to keep that size consistent across the team, under choosing and tuning a local container runtime.

The defaults — half the host's memory on recent versions, 2 GiB on older installs, and every CPU core — are wrong in opposite directions for most development stacks, which is why the problem shows up so often on otherwise healthy laptops.

Diagnostic

Confirm that a kill came from memory pressure and compare the VM's size with what the stack uses:

#!/usr/bin/env bash
set -euo pipefail
for c in $(docker ps -aq); do
  docker inspect --format '{{.Name}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' "$c"
done | grep -E 'exit=137|oom=true' || echo "no OOM-killed containers"
docker info --format 'VM memory: {{.MemTotal}} bytes, CPUs: {{.NCPU}}'
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}'

Expected bad output:

/shop-db-1 exit=137 oom=true
/shop-search-1 exit=137 oom=false
VM memory: 2079997952 bytes, CPUs: 10
NAME            MEM USAGE / LIMIT     MEM %
shop-api-1      612MiB / 1.937GiB     30.87%
shop-web-1      498MiB / 1.937GiB     25.11%
shop-worker-1   402MiB / 1.937GiB     20.27%

The VM has about 2 GiB, the three survivors already use 1.5 GiB of it, and the database was the kernel's chosen victim. oom=false with exit 137 on the search container means it was killed by something else — usually a service-level mem_limit or a manual docker kill — so check its own limit before raising the VM size.

Stack Memory Against a 2 GiB VM Bar chart of per-service memory usage against the total VM memory available. Stack Memory Against a 2 GiB VM api 612 MiB web 498 MiB worker 402 MiB db (before kill) 1.1 GiB VM total 1.9 GiB
Four services need roughly 2.6 GiB at steady state; the 2 GiB VM cannot hold them.

Root cause

Docker Desktop runs containers in one Linux VM whose memory is reserved from the host at start. Inside the VM there is no swap to speak of, so when the containers together exceed the VM's memory, the Linux out-of-memory killer picks the process with the highest badness score — usually the largest — and sends SIGKILL, which Docker reports as exit code 137. The CPU setting fails the other way: giving the VM every core lets a parallel build or a runaway file watcher starve the host, and on macOS the thermal response throttles everything, including the build. Neither setting is stored in the repository, so each developer's VM ends up sized by whatever they last clicked, and the team debugs identical Compose files behaving differently on every laptop.

Resolution

  1. Measure steady state with the full stack running. Bring up everything, exercise the main flows for a minute, then sum the usage:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d
sleep 90
docker stats --no-stream --format '{{.MemUsage}}' | awk '{u=$1; if (u ~ /GiB/) {sub(/GiB/,"",u); t+=u*1024} else {sub(/MiB/,"",u); t+=u}} END {printf "steady state %.0f MiB\n", t}'
  1. Compute the VM size. Steady state × 1.5 for spikes, plus 2 GiB for BuildKit during image builds, capped at half of physical memory. For a stack using 2.6 GiB on a 16 GiB laptop: 2.6 × 1.5 + 2 ≈ 5.9, so 6 GiB. For CPUs, leave at least two cores for the host: 8 of 10 on an M-series Pro, 6 of 8 on an 8-core laptop.

  2. Apply the setting. Use Settings → Resources in the GUI, or write the values into Docker Desktop's settings file and restart it so the change is scriptable:

#!/usr/bin/env bash
set -euo pipefail
f="$HOME/Library/Group Containers/group.com.docker/settings-store.json"
[ -f "$f" ] || f="$HOME/Library/Group Containers/group.com.docker/settings.json"
tmp="$(mktemp)"
jq '.MemoryMiB = 6144 | .Cpus = 8 | .SwapMiB = 1024' "$f" > "$tmp" && mv "$tmp" "$f"
osascript -e 'quit app "Docker"'
open -a Docker
until docker info >/dev/null 2>&1; do sleep 2; done
docker info --format 'VM memory {{.MemTotal}} CPUs {{.NCPU}}'

Recent Docker Desktop versions use settings-store.json with the keys shown; older versions use settings.json with memoryMiB and cpus. Check the file before scripting against it.

  1. Add per-service limits so a runaway service is killed alone rather than taking the database with it, and cap JVM heaps below their container limit:
services:
  db:
    image: postgres:16.4
    deploy:
      resources:
        limits:
          memory: 1536m
  search:
    image: opensearchproject/opensearch:2.15.0
    environment:
      OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m
    deploy:
      resources:
        limits:
          memory: 1g
          cpus: "2"
From Measurement to Settings Four-step flow from measuring stack memory to applying VM settings and service limits. From Measurement to Settings measure docker stats sum compute x1.5 + 2 GiB build apply VM size settings-store.json limit services deploy.resources
The VM number comes from the measurement, not from the default or a colleague's setting.

Expected output

$ docker info --format 'VM memory {{.MemTotal}} CPUs {{.NCPU}}'
VM memory 6208172032 CPUs 8
$ docker compose ps --format '{{.Service}} {{.State}} {{.Health}}'
api running healthy
db running healthy
search running healthy
web running
worker running
$ docker inspect --format '{{.State.OOMKilled}}' shop-db-1
false

Every service stays up through a full build and test run, and the host keeps two cores and about 10 GiB free for the IDE and browser.

Prevention

  1. Check the VM size in make doctor. Compare docker info against the team baseline and print the fix:
#!/usr/bin/env bash
set -euo pipefail
need_mib=6144
have_mib=$(( $(docker info --format '{{.MemTotal}}') / 1024 / 1024 ))
if [ "$have_mib" -lt $(( need_mib - 256 )) ]; then
  echo "Docker VM has ${have_mib} MiB, stack needs ${need_mib} MiB: Settings > Resources > Memory"; exit 1
fi
  1. Re-measure after adding a service. A new search engine or message broker can add a gigabyte; update the baseline in the same pull request that adds the service.

  2. Keep limits in Compose, not in personal settings, so every developer and CI job gets the same per-service ceilings.

Where Each Platform Stores the Limits Table listing where VM memory and CPU limits are configured on macOS, Windows and Linux. Where Each Platform Stores the Limits Platform VM limit lives in Apply with macOS Desktop settings-store.json restart Docker Windows WSL2 .wslconfig wsl --shutdown Linux Engine no VM, host memory service limits only Every platform compose.yaml limits up --force-recreate
Only the Compose limits live in the repository; the VM setting is checked by the doctor script.

Platform caveats

WSL2: Docker Desktop's resource sliders are disabled with the WSL2 backend. Set limits in %UserProfile%\.wslconfig with [wsl2], memory=6GB and processors=8, then run wsl --shutdown. Without a limit, WSL2 can take up to half of RAM and hold file cache long after containers stop.

macOS (Docker Desktop): memory is reserved lazily but rarely returned to macOS. After a heavy build, Activity Monitor may show the VM using its full allocation even when containers are idle; restarting Docker Desktop returns it.

Apple Silicon (ARM64): amd64 images under Rosetta use more memory than native builds of the same service. Prefer arm64 images for the largest services before raising the VM size.

Rollback

Restore the previous values in the settings file or reset to defaults with Troubleshoot → Reset to factory defaults (which also deletes images and volumes — dump data first). Per-service limits are removed by reverting the Compose change.

#!/usr/bin/env bash
set -euo pipefail
git restore compose.yaml
docker compose up -d --force-recreate

Frequently Asked Questions

Why do my containers get killed even though the host has free memory?

Containers can only use the memory assigned to Docker Desktop's VM, not the host's total. When the VM is full, the Linux OOM killer inside it terminates a process regardless of how much memory macOS or Windows has free.

Should I give Docker Desktop all my CPU cores?

No. Leave at least two cores for the host so the IDE, browser and window server stay responsive during parallel builds. Builds rarely get faster beyond the number of cores the build tool actually uses.

Does adding swap to the VM fix OOM kills?

Swap delays them and makes the stack very slow under pressure. A small swap of 1 GiB absorbs brief spikes; beyond that, raise memory or reduce what runs, for example with Compose profiles.

How do I tell a VM-level OOM kill from a service limit?

docker inspect --format '{{.State.OOMKilled}}' reports true for both. Check whether the service has a deploy.resources.limits.memory value and whether its usage was near it; if it was not near its own limit, the VM ran out.