GitHub Actions exit code 137: the step was killed, not crashed
GitHub Actions exit code 137 is 128 plus signal 9: something outside your process sent it SIGKILL, and on a CI runner that is almost always the kernel out-of-memory killer. Nothing in your code failed, so the fix is to lower the job's peak memory or give it a machine that can hold it.


What this error means
A step ends abruptly with Process completed with exit code 137 and no error above it that explains anything. There is no stack trace, no failing test, no exception and often no output at all after the last line your program printed. The shell may print Killed on its own line first. The failure is frequently intermittent: the same commit passes on a rerun, or fails only when another heavy step ran before it, because what changed was the memory available on the machine rather than the code. When the step was a container, docker run reports the same 137 after the container is killed against its --memory cap. The block below is an illustrative log in the shape a hosted runner prints it, with the memory cap our reproduction used left out; the verbatim run, including the wrapper the cap needed, is in the reproduction section under it.
resident: 384 MB
resident: 448 MB
/home/runner/_temp/e12344c9.sh: line 23: 2139 Killed python3 load-fixtures.py
Process completed with exit code 137.Reproduced on a Latchkey runner
total used free shared buff/cache available
Mem: 7734 753 6212 2 1022 6980
Swap: 0 0 0
resident: 64 MB
resident: 128 MB
resident: 192 MB
resident: 256 MB
resident: 320 MB
resident: 384 MB
resident: 448 MB
/home/runner/.latchkey-job-P3WRIc/_temp/e12344c9-3693-48dc-8895-88c1231e710c.sh: line 23: 2139 Killed sudo systemd-run --scope -q -p MemoryMax=512M -p MemorySwapMax=0 python3 "$HOME/load-fixtures.py"
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
total used free shared buff/cache available
Mem: 7734 1026 5937 2 1023 6707
Swap: 0 0 0
resident: 64 MB
resident: 128 MB
resident: 192 MB
resident: 256 MB
resident: 320 MB
resident: 384 MB
resident: 448 MB
/home/runner/.latchkey-job-P3WRIc/_temp/e12344c9-3693-48dc-8895-88c1231e710c.sh: line 23: 2168 Killed sudo systemd-run --scope -q -p MemoryMax=512M -p MemorySwapMax=0 python3 "$HOME/load-fixtures.py"
[latchkey-bash-wrapper] escalating failed Stage 1 heal to Stage 3The runner retried the step and it failed the same way; this failure needs the fix below.
What 137 actually encodes
A shell reports a signalled process as 128 plus the signal number, so 137 is signal 9, SIGKILL. That matters because of what signal(7) says about it: SIGKILL and SIGSTOP "cannot be caught, blocked, or ignored". Your process was not asked to stop and was given no chance to clean up, flush a log or print a diagnostic. It was removed.
Only something outside the process can do that. On a Linux CI runner the realistic senders are the kernel out-of-memory killer, a container runtime enforcing a memory cap, and timeout --signal=KILL. Latchkey's own exit-code table records the same shortlist and rates 137 as memory-category at confidence 0.95, noting that "on Linux runners under cgroup memory limits, this is overwhelmingly the OOM killer".
Exit 137 is not the same failure as a runtime heap limit, which is worth separating before you change a setting. A Node build that exceeds its own V8 ceiling aborts itself with exit 134 and prints a fatal error report, and a JVM that exhausts its heap throws an exception your build tool reports. Those are covered in JavaScript heap out of memory and OutOfMemoryError: Java heap space. A 137 has neither, because the process never ran long enough to complain.
Common causes
The job asked for more memory than the runner has
The usual one. A test suite that forks per core, a build that holds its whole output in memory, or a dataset that grew past the machine. The kernel records the kill in its own log with a line such as "Out of memory: Killed process 12345 (python3) total-vm:12790640kB, anon-rss:12702024kB", which never reaches the step output, so the step just stops.
A container hit its memory cap
A step that runs docker run with --memory, or a job with a container: block, is killed against that cap rather than against the machine. The container runtime reports the same 137 to the step, so the log looks identical while the number that needs changing is in your workflow rather than on the runner.
Something sent SIGKILL on purpose
A wrapper using timeout --signal=KILL, a supervisor script, or a job cancellation can all produce a 137 that has nothing to do with memory. This is rarer than the OOM case, and it is worth ruling out before you spend money on a larger runner.
A heap ceiling was raised above the machine
Setting a runtime heap limit larger than the runner's RAM turns a clean, reported failure into a kill. The process grows past what the machine can back, and the kernel resolves it. In our experience this is the second failure people hit, immediately after applying a generic answer that told them to set a bigger number.
How to fix it
Confirm it was the out-of-memory killer
- Add a step after the failing one that runs on failure and reads the kernel log, so the next occurrence carries its own proof.
- Look for an
Out of memory: Killed processorMemory cgroup out of memoryline naming your process. - If no such line exists, look for a
timeoutor a supervisor in the step instead: that 137 is not about memory.
- name: Why did it die
if: failure()
run: |
sudo journalctl -k --since "-5 min" --no-pager | grep -i "out of memory" || true
free -mLower the peak the job reaches
Pin the parallelism instead of deriving it from the core count, split a matrix job so each shard holds less, and stream large files rather than reading them whole. Halving concurrency is usually a bigger win than any flag, because every worker carries its own copy of the working set.
- run: npx jest --maxWorkers=2 --workerIdleMemoryLimit=1G
- run: ./gradlew test --max-workers=2
- run: pytest -n 2Raise the cap the container is enforcing
When the kill came from a --memory limit, raise or remove that limit rather than the runner size. Keep it below the machine, otherwise you have simply moved the kill from the container runtime to the kernel.
docker run --memory=6g --memory-swap=6g my-image ./run-tests.shGive the job a machine that can hold it
If the work genuinely needs the memory, the only real fix is a runner with more of it. A larger GitHub-hosted runner, or a managed runner sized to the job, removes the ceiling instead of moving it, and it is cheaper than the reruns a borderline job generates.
jobs:
test:
runs-on: latchkey-mediumWhy a retry alone does not fix it
A retry is the right first move and a poor last one. Some 137s really are transient: a peak that collided with another process, a cache restore that briefly doubled memory, a runner that was already loaded. Those pass on the second attempt and cost nothing to retry.
A genuine over-allocation does not. On the run recorded above the step asked for three times the memory it was allowed, the runner retried it once, and the second attempt was killed at the same point, then escalated. That is the honest limit of an automatic retry: it can absorb a coincidence, and it cannot make a 1.5 GB working set fit in 512 MB.
So read the retry as a diagnosis. A 137 that clears on a retry was contention. A 137 that reproduces at the same point is a sizing problem, and the next change should be to the peak or to the machine.
Sizing the machine rather than the flag
Standard GitHub-hosted Linux runners give a private-repository job 2 vCPU and 8 GB of RAM, and a public-repository job 4 vCPU and 16 GB. That difference is behind a surprising share of 137 reports: a workflow that has always passed starts failing when the repository goes private, and nothing in the code changed.
Raising a language runtime's heap ceiling above the machine is the one fix that reliably makes 137 worse. A higher ceiling lets the process grow further before the kernel steps in, which converts a clean, diagnosable runtime error into a silent kill. Set ceilings under the machine, and change the machine when the work genuinely needs more.
jobs:
test:
runs-on: latchkey-medium # 4 vCPU and 16 GB against 2 vCPU and 8 GB
steps:
- uses: actions/checkout@v7
- run: ./run-integration-tests.shHow to prevent it
- Pin worker and fork counts in CI rather than reading them from the machine.
- Keep every runtime heap ceiling below the runner's RAM.
- Record
free -mand the kernel log on failure, so the next 137 arrives with its own evidence. - Watch for the public-to-private repository change: the same workflow gets half the CPU and half the RAM.
Frequently asked questions
How do I handle exit code 137 on Docker?
--memory set means it hit that cap. Raise the cap if the workload genuinely needs it, keeping it under the host's RAM, or lower what the container holds in memory. A 137 with no --memory flag means the host itself ran out.Does exit code 137 always mean out of memory?
timeout --signal=KILL, from a supervisor script, or from a cancelled job. The kernel log is what separates them: an out-of-memory kill leaves an Out of memory: Killed process line, and a watchdog does not.