You run docker compose up and a service flips to Exited (0), Exited (1), or Exited (137) within a second, before logging anything useful — one of the most common local failure points blocking onboarding. A container is not a virtual machine that stays alive until you shut it down; it lives exactly as long as its foreground process. The moment that process returns, the container is finished, and a new engineer cloning the repository sees the service vanish before the app has a chance to print a stack trace. This page walks the exit code back to a root cause and gives you a runnable fix for each of the three classes you will actually hit.

Diagnostic

The default docker compose ps hides stopped containers, so the first mistake is assuming the service never started. List containers including stopped ones, read the exit code, then pull whatever the process managed to log before it died.

#!/usr/bin/env bash
set -euo pipefail
docker compose ps -a
docker compose logs --tail=50 app
docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Error}}' "$(docker compose ps -aq app)"

Expected BAD output — the service is gone within a second and the exit code tells you which class of failure occurred:

NAME      IMAGE        STATUS                     PORTS
app-app-1 app:local    Exited (137) 2 seconds ago

137 true

If the failure is too fast to catch in ps, watch the runtime narrate the lifecycle in real time. In a second terminal run docker events --filter 'type=container' --filter 'container=app-app-1', then docker compose up in the first; you will see create, start, die, and the exitCode attribute stream past in order, which confirms the container did start rather than never being scheduled. This is the fastest way to distinguish "exits immediately" from "never launches", two symptoms that look identical in a plain ps -a.

The exit code is the single most valuable signal here, because a container that dies before its first log line leaves you nothing else to work with. An exit 0 means the main process finished cleanly — there was simply no long-running command to keep PID 1 alive. Exit 1 is a generic application error, while 2 is a shell builtin misuse, 126 means a file was found but is not executable, and 127 means the command was not found at all. Exit 137 with OOMKilled true means the kernel killed the process for exceeding its memory limit: the value is 128 + 9, where 9 is SIGKILL. Its cousin 143 is 128 + 15 (SIGTERM), which usually means an orchestrator or docker stop asked the container to leave. Commit these four numbers to memory and most triage becomes a lookup rather than a guess.

Routing a container exit code to its cause A decision starting from the exit code branches to three root causes: no long-running process, a bad command, or an out-of-memory kill. Read the Exit Code First Exit code? docker inspect Exit 0 no long-running PID 1 Exit 1 / 126 / 127 bad or missing command Exit 137 OOM killed (SIGKILL)
The exit code is the branch selector: 0, 1/126/127, and 137 each point at a different root cause.

Root cause

A container lives exactly as long as its PID 1 process. If the image has no long-running CMD, PID 1 exits immediately and the container stops with code 0 — common when a base image's command is overridden to something like bash with no script, or a shell entrypoint reaches its last line and returns. Code 1/127 means PID 1 itself failed to run: a missing binary, a command not found, a script without an execute bit, or a wrong shebang line such as #!/bin/bash in an Alpine image that only ships /bin/sh. Code 137 is distinct — the process started fine but the cgroup memory limit (or Docker Desktop's VM allocation) was too low, so the OOM killer reaped it. Because PID 1 dies before the app logs anything, the logs are empty and the only signal is the exit code and the OOMKilled flag.

Two subtleties trip people up. First, backgrounding: if your entrypoint runs node server.js & or starts the app under a process manager that daemonizes, the shell that launched it becomes PID 1, reaches the end of the script, and returns 0 — so the app is technically "running" for a few milliseconds inside a container that is already tearing down. Second, signal handling: a shell-form CMD server does not run your binary as PID 1; it runs /bin/sh -c server, and the shell may not forward signals or may exit with its own code. The exec form CMD ["server"] makes your binary PID 1 directly, which is almost always what you want for a long-running service.

There is also a reaping angle. When your binary is PID 1 it inherits the duty of reaping zombie child processes, and many application runtimes never do this, so a long-lived container can slowly accumulate defunct processes even when it does not exit at startup. If you spawn child processes, add a lightweight init such as tini by running the container with docker run --init or setting init: true on the Compose service; that puts a proper init at PID 1 to forward signals and reap children while your app runs as its child. This does not cause the immediate-exit symptom on its own, but it removes a whole class of "works then wedges" follow-on failures once the startup crash is fixed.

Container lifetime is bound to PID 1 A left-to-right flow showing that when PID 1 returns, the container stops regardless of any backgrounded work. The Container Follows PID 1 compose up runtime starts PID 1 PID 1 returns CMD ends or crashes container stops status = Exited A backgrounded app cannot keep a container alive — only PID 1 can.
Container lifetime equals PID 1 lifetime; anything you daemonize dies with the shell that started it.

Resolution

  1. Read the exit code to pick the branch (0 = no long process, 1/127 = bad command, 137 = OOM).
  2. For exit 0, ensure a foreground, long-running CMD and that the entrypoint does not background the app.
  3. For exit 1/127, run the image interactively to reproduce the failing command directly.
  4. For exit 137, raise the memory limit (and Docker Desktop's VM memory) and re-check.

For exit 0 — give PID 1 a foreground process and stop backgrounding it. Use the exec form so your server becomes PID 1 and receives SIGTERM on shutdown:

# Dockerfile — run the server in the foreground as PID 1
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
# WRONG: `node server.js &` backgrounds it and the shell exits -> Exited (0)
CMD ["node", "server.js"]

For exit 1/127 — reproduce interactively, bypassing the entrypoint so you land in a shell instead of re-running the broken command. This is faster than editing the Dockerfile and rebuilding on every guess:

#!/usr/bin/env bash
set -euo pipefail
# Drop into a shell to find the missing binary / bad shebang / permission bit.
docker compose run --rm --entrypoint sh app -c 'ls -l /app/entrypoint.sh; head -1 /app/entrypoint.sh; which node'

If the listing shows -rw-r--r-- instead of an x bit, the script is not executable; fix it at build time with RUN chmod +x /app/entrypoint.sh rather than relying on the host file mode, which is not preserved across every copy path. If head -1 shows #!/bin/bash on an Alpine base, either install bash or switch the shebang to #!/bin/sh. If which node prints nothing, your PATH or base image is wrong for the command you are invoking.

For exit 137 — set an explicit limit and confirm it fits the workload. Under Compose, deploy.resources.limits is honoured by the local engine even without Swarm:

# docker-compose.yml
services:
  app:
    build: .
    deploy:
      resources:
        limits:
          memory: 512M
    mem_swappiness: 0

Then watch live usage while you exercise the app so you size the limit from evidence rather than a round number: docker stats --no-stream app-app-1 prints the current MEM USAGE / LIMIT. If peak usage sits within a few percent of the limit, raise it; a limit should have headroom above the working set, not hug it.

The exit-1 interactive triage loop Four ordered stages for reproducing a command failure inside the image without rebuilding. Reproduce Exit 1 Interactively 1 — override entrypoint with sh 2 — check perms and shebang 3 — run the command by hand 4 — fix in Dockerfile, rebuild
Bypassing the entrypoint turns a rebuild-per-guess loop into a single interactive session.

Expected output

After fixing PID 1, the service stays up and reports a running, healthy state:

$ docker compose ps
NAME       IMAGE       STATUS                   PORTS
app-app-1  app:local   Up 30 seconds (healthy)  0.0.0.0:3000->3000/tcp

Inspecting a recovered container shows a zero exit code is no longer being hit and OOM is false:

$ docker inspect --format '{{.State.Status}} {{.State.OOMKilled}}' app-app-1
running false

The (healthy) suffix only appears when a healthcheck is defined; without one the status stops at Up 30 seconds, which tells you the process is alive but not that it is actually serving requests. That distinction matters for the exit-0 class especially, because a service can be "up" for exactly as long as it takes to fail its first real request.

Prevention

  1. Add a Compose healthcheck so a process that starts but immediately dies is reported as unhealthy rather than silently restarting — see resolving service startup-order and healthcheck races.
  2. Run make doctor before up so memory and tool preconditions are checked — see building an onboarding health-check script.
  3. Pin a foreground CMD in the image and never background the app in the entrypoint.

A minimal healthcheck turns a silent early exit into a visible state that CI and depends_on can act on:

# docker-compose.yml
services:
  app:
    build: .
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s

The memory numbers below make the exit-137 case concrete: the default limit sat under the app's real peak, so the OOM killer fired on startup. Raising the limit above the working set — with headroom, not a hug — is what makes 137 stop recurring.

Why exit 137 fired, in megabytes Bar chart comparing the old memory limit, the app's peak usage, and the raised limit in megabytes. Memory: Limit vs Peak (MB) old limit 256M peak RSS 410M — OOM new limit 512M
Peak usage (410M) exceeded the 256M limit, triggering the SIGKILL; 512M restores headroom.

Platform caveats

macOS / Windows (Docker Desktop): exit 137 is frequently the VM's memory ceiling, not the container's. Raise Resources > Memory in Docker Desktop before lowering suspicion of a leak. WSL2: the WSL VM has its own memory cap; set [wsl2] memory=8GB in %UserProfile%\.wslconfig and run wsl --shutdown to apply, or large builds OOM at 137. Apple Silicon (ARM64): an image without an arm64 manifest exits with code 1 and exec /bin/sh: exec format error; add platform: linux/amd64 to the service so it runs under emulation.

Rollback

#!/usr/bin/env bash
set -euo pipefail
docker compose down && git checkout -- Dockerfile docker-compose.yml   # revert image/limit edits

Frequently Asked Questions

Why do docker compose logs show nothing when the container exits?

Because PID 1 died before your application reached the code that logs. The container captured stdout/stderr for the lifetime of the process, but if the failure is a missing binary, a bad shebang, or an immediate return, there was no output to capture. Read docker inspect --format '{{.State.Error}}' and the exit code instead — those come from the runtime, not the app, so they survive an empty log.

What is the difference between exit 137 and exit 143?

Both are 128 + signal. 137 is 128 + 9 (SIGKILL), which the OOM killer sends and which a process cannot trap — usually a memory-limit breach. 143 is 128 + 15 (SIGTERM), the graceful stop signal sent by docker stop or an orchestrator; if you see it at startup, something is asking your container to leave, not killing it for resources.

My container exits 0 but I never told it to stop — why?

Exit 0 means PID 1 finished successfully. The usual cause is that the command is not long-running: a shell entrypoint reached its last line, or the app was backgrounded (node server.js &) so the shell became PID 1 and returned. Run the real server in the foreground with the exec form, CMD ["node", "server.js"], so the container's lifetime tracks the app's.

Does adding restart: unless-stopped fix an immediate exit?

No — it hides it. A restart policy just relaunches the same broken image, so an exit-0 or exit-1 container enters a crash loop that burns CPU and floods docker events. Fix the root cause first; use restart: only for transient runtime failures, and pair it with a healthcheck so a container that starts but cannot serve is marked unhealthy rather than endlessly restarted.