Skip to content
Latchkey LogoLatchkey home

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.

Runner log: a step killed at 448 MB under a 512 MB cap, retried, and killed again
The recorded run: the step is killed, the runner retries it once, and the second attempt dies at the same point. A retry cannot create memory.
Diagram of a step killed by SIGKILL against runner memory, with the fix and the runner action
Measured on a latchkey-small runner: 7,734 MB of RAM, a step capped at 512 MB, killed at 448 MB resident with exit 137.

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.

Actions log, test step
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

Run 2026-09-19·Runner latchkey-small·Exit code 137

               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 3

The 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

  1. Add a step after the failing one that runs on failure and reads the kernel log, so the next occurrence carries its own proof.
  2. Look for an Out of memory: Killed process or Memory cgroup out of memory line naming your process.
  3. If no such line exists, look for a timeout or a supervisor in the step instead: that 137 is not about memory.
.github/workflows/ci.yml
- name: Why did it die
  if: failure()
  run: |
    sudo journalctl -k --since "-5 min" --no-pager | grep -i "out of memory" || true
    free -m

Lower 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.

.github/workflows/ci.yml
- run: npx jest --maxWorkers=2 --workerIdleMemoryLimit=1G
- run: ./gradlew test --max-workers=2
- run: pytest -n 2

Raise 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.

Terminal
docker run --memory=6g --memory-swap=6g my-image ./run-tests.sh

Give 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.

.github/workflows/ci.yml
jobs:
  test:
    runs-on: latchkey-medium

Why 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.

.github/workflows/ci.yml
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.sh

How 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 -m and 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?
Docker returns 137 when the container was killed with SIGKILL, which for a container with --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?
No, but on a Linux CI runner it usually does. SIGKILL can also come from 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.
Why did my job fail with 137 and no error message?
Because SIGKILL cannot be caught. The process was not asked to exit, so it never ran an exception handler, never flushed its output and never printed a diagnostic. The absence of a message is itself the signal that something outside the process ended it.
Will a larger runner fix exit code 137?
If the kill was a genuine over-allocation, yes, and it is the only fix that lasts. If it was contention or a one-off peak, a retry is enough and a larger runner is money spent on a coincidence. Reproducing the failure twice at the same point tells you which one you have.

Related guides

References

Exit 137 is the kernel, not an exception. Latchkey sizes start at $0.0025/min at 2 vCPU with 8 GB. Start free → 30-day trial · No credit card