Skip to main content

Entrypoint, CMD, and Signal Handling

A container's lifecycle is tied to its main process (PID 1). When PID 1 exits, the container exits. When Docker sends a stop signal, PID 1 must handle it cleanly. If your container ignores stop signals, takes too long to shut down, or leaks zombie processes, the problem is almost always in how ENTRYPOINT, CMD, and signal handling are configured.

How Container Shutdown Works

When you run docker stop, Docker sends signals to the container's main process in two phases:

sequenceDiagram
participant User
participant Docker
participant PID1 as Container (PID 1)

User->>Docker: docker stop my-container
Docker->>PID1: SIGTERM
Note over PID1: Grace period (default 10s)
alt App handles SIGTERM
PID1->>PID1: Stop accepting work
PID1->>PID1: Finish in-flight requests
PID1->>PID1: Close connections
PID1->>Docker: Exit (code 0)
else App ignores SIGTERM
Note over Docker: Timeout expires
Docker->>PID1: SIGKILL (forced)
PID1->>Docker: Exit (code 137)
end

SIGTERM is a polite request to stop. Your application should handle it by finishing current work and exiting cleanly. SIGKILL is a forced kill that cannot be caught -- Docker sends it after the grace period expires.

Exec Form vs Shell Form

This is the most important distinction for signal handling:

Exec Form (Use This)

CMD ["node", "server.js"]
ENTRYPOINT ["python", "app.py"]

The application runs directly as PID 1 and receives signals.

Shell Form (Avoid This)

CMD node server.js
ENTRYPOINT python app.py

Docker wraps this in /bin/sh -c "node server.js". The shell becomes PID 1 and may not forward signals to your application:

flowchart LR
subgraph exec["Exec Form"]
A["PID 1: node server.js"] -->|"Receives SIGTERM"| B["Handles shutdown"]
end

subgraph shell["Shell Form"]
C["PID 1: /bin/sh"] -->|"Receives SIGTERM"| D["Shell may not forward"]
D --> E["node server.js never gets signal"]
end

style exec fill:#e8f5e9,stroke:#2e7d32
style shell fill:#ffebee,stroke:#c62828

Always use exec form for production containers.

ENTRYPOINT vs CMD

InstructionRoleOverridable?
ENTRYPOINTThe executable to runOnly with --entrypoint flag
CMDDefault arguments (or fallback command)Replaced by any args after docker run <image>

Pattern 1: CMD Only (Simple Applications)

CMD ["node", "server.js"]
docker run app              # Runs: node server.js
docker run app --help # Runs: --help (CMD is entirely replaced)

Pattern 2: ENTRYPOINT + CMD (Flexible Applications)

ENTRYPOINT ["node"]
CMD ["server.js"]
docker run app              # Runs: node server.js
docker run app repl.js # Runs: node repl.js (CMD replaced, ENTRYPOINT stays)

When to Use Each

ScenarioUse
Simple app, no argument customization neededCMD only
Fixed executable with variable argumentsENTRYPOINT + CMD
CLI tool with subcommandsENTRYPOINT (the tool) + CMD (default subcommand)

Wrapper Scripts

Sometimes you need a startup script to set up the environment before running your app. The critical rule: end the script with exec so your app replaces the shell as PID 1:

Good Wrapper Script

#!/usr/bin/env sh
set -eu

# Setup tasks (run as shell)
echo "Starting with config: $CONFIG_PATH"

# Hand off to the application (replaces shell as PID 1)
exec node server.js "$@"

Bad Wrapper Script

#!/usr/bin/env sh
# Missing exec! Shell stays as PID 1
node server.js "$@"

Without exec, the shell remains PID 1 and may not forward SIGTERM to your application.

Using the Wrapper in a Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh
COPY . .
ENTRYPOINT ["/usr/local/bin/start.sh"]
CMD ["serve"]

Implementing Graceful Shutdown

Your application should handle SIGTERM by:

  1. Stop accepting new requests/work
  2. Finish in-flight requests (with a timeout)
  3. Close database connections and file handles
  4. Exit with code 0

Node.js Example

process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down...');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});

Python Example

import signal
import sys

def shutdown(signum, frame):
print("Shutting down gracefully...")
# Close connections, flush buffers
sys.exit(0)

signal.signal(signal.SIGTERM, shutdown)

Adjusting the Stop Timeout

If your application needs more than 10 seconds to shut down (e.g., finishing long-running requests), increase the stop timeout:

# Give 30 seconds for graceful shutdown
docker stop --time 30 my-container

In Compose:

services:
api:
image: my-api:1.0.0
stop_grace_period: 30s

If the timeout is too short, Docker sends SIGKILL and your app is force-killed -- potentially losing in-flight work.

Debugging Process Issues

# Check what ENTRYPOINT and CMD are configured
docker inspect -f '{{json .Config.Entrypoint}}' my-container
docker inspect -f '{{json .Config.Cmd}}' my-container

# Check the process tree inside the container
docker exec my-container ps -o pid,ppid,comm

# Test graceful shutdown and watch logs
docker stop my-container && docker logs --tail 50 my-container

What to look for:

  • Is PID 1 your application, or is it /bin/sh?
  • Does the application log a clean shutdown message, or does it just disappear?
  • Does docker stop complete quickly, or does it hang for the full timeout?

Key Takeaways

  • Always use exec form (["node", "server.js"]) for CMD and ENTRYPOINT -- shell form breaks signal delivery.
  • docker stop sends SIGTERM first, then SIGKILL after the grace period. Your app must handle SIGTERM.
  • Wrapper scripts must end with exec to hand PID 1 to the application.
  • Implement graceful shutdown in your application: stop accepting work, finish in-flight tasks, close connections, exit cleanly.
  • If shutdown takes more than 10 seconds, increase stop_grace_period or --time.

What's Next