Exit 137 usually means the kernel shot your container for eating too much memory. Here's how to confirm the OOM kill and stop it happening again.
A container that keeps dying with Exited (137) is one of those failures that looks scary and is actually pretty legible once you know how to read it. The first time it bit me I burned an hour reading application logs that had nothing to do with the problem, because the app never got a chance to log anything. It didn't crash. Something outside it pulled the trigger.
Exit codes above 128 encode a signal. The math is 128 + signal_number. Signal 9 is SIGKILL, so 128 + 9 = 137. SIGKILL is the one signal a process cannot catch, block, or handle. When your container gets it, the process is gone instantly with no cleanup, no shutdown hook, no final log line.
The usual sender of that SIGKILL is the Linux OOM (out-of-memory) killer. When memory runs out, the kernel picks a process and kills it to reclaim RAM. In a Docker world "memory runs out" happens at two very different boundaries, and telling them apart is the whole job.
Don't guess. Ask Docker directly:
$ docker inspect --format '{{.State.OOMKilled}}' myapp
true
If that prints true, the kernel OOM-killed your container. Full stop. You can see the same thing in the raw state:
$ docker inspect --format '{{json .State}}' myapp | jq
{
"Status": "exited",
"OOMKilled": true,
"ExitCode": 137,
"Error": ""
}
Then confirm on the host kernel side with dmesg:
$ dmesg -T | grep -i -E 'oom|killed process'
[Tue Jul 7 14:22:10 2026] Memory cgroup out of memory: Killed process 48213 (node) total-vm:2891044kB, anon-rss:1048180kB
[Tue Jul 7 14:22:10 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=...
That constraint=CONSTRAINT_MEMCG is the tell. MEMCG means the kill came from a memory cgroup hitting its limit, which is the container's own --memory cap. If instead you see constraint=CONSTRAINT_NONE, the whole host ran out of memory and the kernel went hunting for the biggest target. Same exit code, completely different fix.
The container hit its own limit. You told Docker --memory=512m and the process wanted more. The cgroup limit is doing exactly its job. Check what it's really using:
$ docker stats --no-stream
CONTAINER NAME CPU % MEM USAGE / LIMIT MEM %
a1b2c3d4 myapp 0.42% 509.8MiB / 512MiB 99.57%
Sitting at 99% against the limit right before it died is your smoking gun. Either the limit is too low for honest workload, or the app is leaking.
The host ran out. No per-container limit, or limits that sum to more than physical RAM, and everything competes. Here the container that gets killed often isn't the one at fault, it's just the fattest. Check the host:
$ free -m
total used free shared
Mem: 7820 7511 120 210
120MB free on an 8GB box means you're overcommitted and living on borrowed time.
The lazy fix is to bump --memory until the kills stop. Don't. Measure first, then set a limit with headroom over the real steady-state peak:
docker run -d --name myapp \
--memory=1g \
--memory-swap=1g \
myapp:latest
Setting --memory-swap equal to --memory disables swap for the container, which is what you usually want in production. If you leave --memory-swap unset it defaults to double --memory, quietly letting the container swap and hiding the real footprint until swap thrashing tanks your latency instead. Be deliberate about it.
This is the trap that catches people who "already set a limit." Older runtimes look at the host's total RAM, not the container's cgroup limit, and size their heap for a machine that doesn't exist. A JVM on an 8GB host inside a 1GB container will happily plan a 2GB heap and get shot the moment it grows into it.
Tell the runtime the truth:
# JVM: size the heap as a fraction of the CGROUP limit, not the host
java -XX:MaxRAMPercentage=75.0 -jar app.jar
# Node: cap the old-space heap under your container limit
node --max-old-space-size=768 server.js
For a 1GB container, --max-old-space-size=768 leaves room for the non-heap overhead (buffers, native modules, the V8 baseline) that lives outside the old space and still counts against your cgroup. Set it to the full 1024 and you've reserved zero headroom for everything else.
If usage climbs steadily and never plateaus, no limit will save you, it just changes how long until the kill. Watch the slope over time:
$ docker stats myapp
CONTAINER MEM USAGE / LIMIT
myapp 412MiB / 1GiB # 10:00
myapp 587MiB / 1GiB # 10:30
myapp 761MiB / 1GiB # 11:00
A line that only goes up is a leak. Fix the code. The limit is a seatbelt, not a cure.
If you run several containers on one box, their limits should sum to comfortably less than physical RAM, with slack for the kernel, page cache, and the Docker daemon itself. Overcommitting on the promise that "they won't all peak at once" works right up until they do, and then the host-level OOM killer makes the choice for you, badly.
If OOMKilled is false but the exit code is still 137, something sent SIGKILL by hand. A docker kill myapp (which sends SIGKILL by default), an orchestrator killing a container that ignored SIGTERM during shutdown, or a docker stop that timed out and escalated. Same number, no OOM involved. That's exactly why you check .State.OOMKilled first instead of trusting the code alone.
If you're chasing a broader set of failures, our Docker troubleshooting guide covers the neighboring exit codes. And the container-orchestrator version of this exact problem shows up as Kubernetes OOMKilled, where the pod status says it plainly and the cgroup mechanics underneath are identical.
Run docker inspect --format '{{.State.OOMKilled}}' before you touch anything else. If it's true, check dmesg for CONSTRAINT_MEMCG versus CONSTRAINT_NONE to know whether it was the container's limit or the whole host. Set an explicit --memory with --memory-swap equal to it, size it from docker stats peaks plus headroom, and make the runtime cgroup-aware with MaxRAMPercentage or --max-old-space-size. If memory only ever climbs, stop tuning limits and go find the leak.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Both promise to find your slow query at 3am. One bills by data ingested, the other by host-hour. Here's how that shakes out in a real ops budget.
A shared API key between two internal services proves nothing about who is calling. mTLS makes every service present a cryptographic identity instead.
Explore more articles in this category
The IaC landscape fractured after the Terraform license change. This is the map to what each tool is actually best at, and how to choose without regret.
Terragrunt keeps large Terraform setups DRY and orchestrated, but small teams often pay its learning curve for little return.
A practical comparison of Kubernetes-native continuous reconciliation against the classic CLI-driven, state-file IaC model for platform teams.
Evergreen posts worth revisiting.