Nurture TechnologiesNurture Tech
DevOps15 min·July 24, 2026

Docker Container Keeps Restarting in Production

DockerDocker ComposeLinuxNode.jsPostgreSQL

Your container starts, crashes, and Docker immediately restarts it over and over. Systematically diagnose OOM kills, missing startup environment variables, health check misconfiguration, dependency race conditions, and PID 1 signal handling to stop the restart loop.

Problem Summary

A Docker container in a restart loop is one of the most time-pressured production incidents because the container is continuously crashing and the application is unavailable or intermittently available. The restart behavior (driven by restart: always or --restart=on-failure) is designed to recover from transient failures but becomes a crash loop when the underlying cause is persistent. The key is reading the exit code and the last few log lines from the crashed container these almost always reveal the cause within minutes.

Symptoms

  • docker ps shows the container status flipping between 'Up X seconds' and 'Restarting'
  • docker logs shows the application starting up and then abruptly stopping with no error message, or with an OOM error
  • The container runs for a predictable amount of time (e.g., always crashes after ~30 seconds) suggesting a health check timeout
  • The container starts fine in docker-compose up but crashes when run in production with docker run
  • Error message: 'cannot connect to database' or 'ECONNREFUSED' on startup
  • docker inspect shows exit code 137 (OOM kill) or exit code 1 (application error)
  • The application works in the container for a few minutes then crashes under load
  • Health check status shows 'unhealthy' repeatedly before each restart

Business Impact

A restarting container means your application is continuously unavailable or serving requests only during the brief up-time between restarts. Depending on restart backoff settings, the container might be up for only seconds every few minutes. If the container hosts an API, every in-flight request during a restart is lost. For stateful applications, repeated unclean shutdowns can corrupt in-memory state or leave database transactions uncommitted.

Root Cause

Container restart loops have five primary causes: (1) OOM (out of memory) kills where the container hits the memory limit and the kernel terminates it with SIGKILL, (2) application crash on startup due to missing environment variables or inability to connect to a database that is not yet ready, (3) health check misconfiguration where the health check path, port, or start period is wrong causing the container to be considered unhealthy and killed, (4) depends_on in Docker Compose which only waits for the container to start, not for the service inside it to be ready, and (5) PID 1 signal handling where the application does not handle SIGTERM gracefully and Docker kills it after a timeout.

Exit code 137 means the process was killed by signal 9 (SIGKILL) this is always an OOM kill by the kernel or Docker. Exit code 1 means the process exited with an error. Exit code 0 means the process exited cleanly (your entrypoint or CMD finished without error but exited).

Diagnosis

Step 1 Read the Exit Code and Last Logs

The exit code and the final log lines from the crashed container tell you most of what you need. Use docker inspect to get the exit code and docker logs to see what the container printed before dying.

$docker inspect <container_name> --format '{{.State.ExitCode}} {{.State.Error}} {{.State.OOMKilled}}'
$docker logs <container_name> --tail 100 2>&1
$docker events --filter 'container=<container_name>' --filter 'event=die' --since 1h

Step 2 Diagnose OOM Kills

If OOMKilled is true in docker inspect, the container is hitting its memory limit. Check the container's memory usage over time and compare to its limit.

$docker stats <container_name> --no-stream --format 'table {{.Container}} {{.MemUsage}} {{.MemPerc}}'
$docker inspect <container_name> --format '{{.HostConfig.Memory}}' | awk '{printf "Memory limit: %.0f MB\n", $1/1024/1024}'
docker-compose.yml
services:
  app:
    image: my-app:latest
    deploy:
      resources:
        limits:
          memory: 512M   # Increase if OOM killing
          cpus: "1.0"
        reservations:
          memory: 256M
    # For older docker-compose syntax:
    mem_limit: 512m
    memswap_limit: 512m   # Set equal to mem_limit to disable swap

For Node.js applications, add the --max-old-space-size flag to match the container memory limit minus ~100 MB overhead: node --max-old-space-size=400 server.js for a 512 MB container. Without this, Node.js will try to use more memory than the container allows and get OOM killed.

Step 3 Identify Application Startup Crashes

If the container exits with code 1 immediately after startup, look for missing environment variables or failed database connections. Run the container without restart policy to see the full output before it disappears.

$docker run --rm --env-file .env.production my-app:latest 2>&1 | head -50
wait-for-db.sh
#!/bin/bash
# Use this as your container entrypoint to wait for database readiness
set -e

host="${DB_HOST:-postgres}"
port="${DB_PORT:-5432}"
max_attempts=30
attempt=0

echo "Waiting for $host:$port to be ready..."

until pg_isready -h "$host" -p "$port" -U "${DB_USER}" 2>/dev/null; do
  attempt=$((attempt + 1))
  if [ $attempt -ge $max_attempts ]; then
    echo "Database not ready after $max_attempts attempts. Exiting."
    exit 1
  fi
  echo "Attempt $attempt/$max_attempts  sleeping 2 seconds..."
  sleep 2
done

echo "Database is ready. Starting application..."
exec "$@"

Step 4 Fix Health Check Configuration

A misconfigured health check will kill a container that is actually running correctly. The most common mistakes: wrong port, wrong path, start_period too short (application has not finished initializing yet), and interval too short (health checks run before the previous one finishes).

docker-compose.yml
services:
  app:
    image: my-app:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s        # How often to run the check
      timeout: 10s         # How long to wait for a response
      retries: 3           # Failures before marking unhealthy
      start_period: 60s    # Grace period after startup before checks count
    ports:
      - "3000:3000"
$docker inspect <container_name> --format '{{json .State.Health}}' | jq '.Log[-3:]'

Step 5 Fix depends_on Race Conditions and PID 1 Signal Handling

Docker Compose's depends_on only waits for the dependent container to start, not for the service inside it to be ready. Use the condition: service_healthy form combined with health checks on the dependency.

docker-compose.yml
services:
  postgres:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

  app:
    image: my-app:latest
    depends_on:
      postgres:
        condition: service_healthy   # Waits for postgres healthcheck to pass
    # Use tini or dumb-init as PID 1 for proper signal handling
    init: true   # Docker's built-in init (tini)
    # OR in Dockerfile: ENTRYPOINT ["tini", "--", "node", "server.js"]

When your application runs as PID 1 in a container, it receives signals directly (SIGTERM on docker stop). Many runtimes (Node.js, Python) do not handle SIGTERM by default when running as PID 1. Add init: true to use Docker's built-in tini init process, which properly forwards signals and reaps zombie processes.

Production Failure Scenarios

Scenario 1 Node.js Heap Grows Slowly Until OOM Kill

A memory leak in the application (uncleaned event listeners, growing cache without eviction) causes the Node.js heap to grow gradually until hitting the container memory limit. The container runs fine for hours then gets killed. Symptom: crash happens on a schedule, not at startup. Fix: add --max-old-space-size, implement memory leak profiling with clinic.js, and add a /health endpoint that returns 503 if memory usage exceeds 90% of the limit.

Scenario 2 Health Check Hitting Non-Existent Endpoint

The Dockerfile defines a HEALTHCHECK to curl /healthz but the application only has a /health endpoint. Every health check returns a 404, Docker marks the container unhealthy after 3 retries, and kills it. The application itself is running fine the health check is wrong. Fix: always test your health check command manually inside the container with docker exec <container> curl http://localhost:3000/health

Scenario 3 Container Exits with Code 0 Immediately

The CMD or ENTRYPOINT in your Dockerfile runs a script that exits cleanly (exit 0) after completing setup, before the main process starts. Example: CMD ["sh", "-c", "npm run migrate && npm start"] if npm run migrate runs as a background process or the shell exits before npm start is ready. Fix: always exec the final process: CMD ["sh", "-c", "npm run migrate && exec npm start"]

Scenario 4 Missing Secret Causes Crash After Rotation

An API key or database password was rotated in the secrets manager but the Docker container's environment variable was not updated. The application starts, tries to authenticate with the old key, fails, and crashes. Fix: use Docker secrets or a secrets management sidecar that refreshes credentials without restart, and implement retry logic for authentication failures at startup.

Prevention Checklist

  • Always check exit codes with docker inspect 137 means OOM, 1 means app error, 0 means CMD exited (likely wrong entrypoint)
  • Set --max-old-space-size for Node.js to container memory limit minus 100 MB overhead
  • Add start_period to health checks that is longer than your slowest measured startup time plus 30 seconds buffer
  • Test health check commands manually with docker exec before deploying
  • Use depends_on with condition: service_healthy instead of bare depends_on for any service with a startup time
  • Add init: true to all Docker Compose services or use tini as ENTRYPOINT in Dockerfile for correct PID 1 signal handling
  • Implement a wait-for-it.sh or pg_isready loop in your entrypoint for database dependencies
  • Monitor container memory usage with docker stats and set alerts before the OOM threshold
  • Set up container restart backoff (restart: on-failure:5) to prevent infinite restart loops from burning through resources
  • Use a structured logging format (JSON) so container logs are parseable and searchable in your log aggregation system

Resolution Summary

Read the exit code first: 137 = OOM (increase memory or fix memory leak), 1 = app error (check logs for missing env vars or connection failures), 0 = wrong CMD (fix entrypoint). Then check health check configuration, dependency readiness with depends_on conditions, and PID 1 signal handling. In most cases, one of these five causes is responsible and the fix is a configuration change, not a code change.

Lessons Learned

  • The exit code is the most information-dense signal available read it before reading logs
  • Docker's depends_on is container-ready, not service-ready; the health check condition is mandatory for reliable startup ordering
  • PID 1 signal handling is a container-specific concern that does not exist when running processes outside containers
  • Health check start_period being too short is responsible for a large fraction of false-positive restart loops
  • Memory limits without language-level heap size configuration (Node.js --max-old-space-size) lead to OOM kills even when memory usage looks reasonable
  • Secret rotation events are a common trigger for startup crashes credential refresh without restart is worth the engineering investment

Conclusion

Docker container restart loops are stressful in production but they follow predictable patterns. Exit codes and logs give you the diagnosis within minutes if you know what to look for. Build your container infrastructure with health checks that have generous start periods, depends_on with service_healthy conditions, init: true for signal handling, and explicit memory limits with corresponding language-level heap settings. A container that restarts is a container telling you something is wrong the restart loop is the symptom, not the problem.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

What does Docker exit code 137 mean?+

Exit code 137 means the process received SIGKILL (signal 9). In containers this is almost always an OOM (out of memory) kill either the kernel killed the process because it exceeded the container memory limit, or Docker killed it. Check docker inspect <container> --format '{{.State.OOMKilled}}'

How do I see why a container crashed if it keeps restarting and losing logs?+

Use docker logs <container_name> --tail 100 Docker retains logs from previous runs of a restarting container. Also run docker events --filter event=die --since 1h to see the exit codes of recent crashes.

What is the difference between docker stop and the container crashing?+

docker stop sends SIGTERM, waits 10 seconds for a graceful shutdown, then sends SIGKILL. A crash (OOM kill, unhandled exception) sends SIGKILL immediately without waiting. Both result in a stopped container, but the exit code differs: docker stop typically produces 0 or 143, crashes produce 137 or non-zero.

Why does depends_on not prevent my app from crashing when the database is not ready?+

depends_on (without a condition) only waits for the database container to exist and start not for PostgreSQL to finish initializing and accept connections. This takes 2-10 seconds. Use depends_on with condition: service_healthy and a proper healthcheck on the database service.

How do I add a health check to my Docker container?+

Add it in docker-compose.yml: healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:3000/health'] interval: 30s timeout: 10s retries: 3 start_period: 60s. Or in Dockerfile: HEALTHCHECK --interval=30s --timeout=10s --start-period=60s CMD curl -f http://localhost:3000/health

What is PID 1 and why does it matter in Docker?+

PID 1 is the first process in a container and has special responsibilities: it receives signals from Docker (SIGTERM on stop), must forward signals to child processes, and must reap zombie processes. Applications not designed as PID 1 often ignore SIGTERM. Use init: true in compose or tini as ENTRYPOINT to add a proper init process.

How do I set Node.js max memory in a Docker container?+

Add the --max-old-space-size flag in MB to your node command: CMD ["node", "--max-old-space-size=400", "server.js"]. Set this to about 80% of your container memory limit to leave room for the OS and other overhead.

My container crashes only in production but not in docker-compose up locally why?+

Common differences: production uses different environment variables (database URLs, API keys), production has memory limits that local does not, production may use a different Docker network configuration, and production Kubernetes/ECS environments may have stricter resource policies. Run docker run with --memory limit to simulate production locally.

How do I prevent a crash loop from consuming all system resources?+

Use restart: on-failure:5 instead of restart: always to cap restart attempts. After 5 consecutive failures, Docker stops restarting the container. In Kubernetes, the CrashLoopBackOff mechanism automatically applies exponential backoff.

What should my /health endpoint return?+

Return HTTP 200 with a simple JSON body when the application is healthy. Return 503 (Service Unavailable) when unhealthy (e.g., database connection lost, memory above threshold). Include enough detail for debugging: { status: 'ok', uptime: 1234, memory: { used: 250, limit: 512 } }