Skip to Content
Docker Intermediate: Images and Compose That Survive Real Projects
Intermediate
0 likes
Courses
Docker Intermediate: Images and Compose That Survive Real Projects

ENTRYPOINT, CMD and When to Override Them

Run the same small image with CMD, with ENTRYPOINT, in exec form and in shell form, and see which one receives the stop signal. After this, you can read any Dockerfile ending and know what runs, what you can replace and why a container will not stop.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If you already know the difference between exec form and shell form, skip to "A real entrypoint script", then go on to the next lesson.

A Dockerfile has two ways to say what runs when the container starts: CMD and ENTRYPOINT. They look alike, people use them interchangeably, and then a container ignores an argument or refuses to stop. The difference is small, and once you see it you can read almost any Dockerfile. This lesson uses a tiny image so every behaviour is visible.

CMD is a default that you can replace

CMD is the default command. Anything you type after the image name in docker run replaces it.

FROM alpine:3.24
CMD ["echo", "hello from CMD"]
Set-Location cmd-demo
docker build -q -t cmd-demo:1 . | Out-Null
docker run --rm cmd-demo:1
docker run --rm cmd-demo:1 echo "something else"
hello from CMD
something else

The second run never printed the default. The words after the image name became the whole command.

ENTRYPOINT is the program, CMD is its default arguments

With ENTRYPOINT, the program is fixed and what you type after the image name is added to it as arguments. CMD then supplies the arguments to use when you type nothing.

FROM alpine:3.24
ENTRYPOINT ["echo", "greeting:"]
CMD ["world"]
Set-Location ..\entry-demo
docker build -q -t entry-demo:1 . | Out-Null
docker run --rm entry-demo:1
docker run --rm entry-demo:1 everyone
greeting: world
greeting: everyone

This is the pattern for an image that behaves like a command, such as a linter or a converter: the entrypoint is the tool, and you pass arguments. If you want to run something else in the image, for example a shell to look around, you have to say so on purpose.

docker run --rm --entrypoint sh entry-demo:1 -c "echo I am the shell now"
I am the shell now

--entrypoint replaces the entrypoint. The arguments after the image name then go to the new program. Docker Compose has the same two keys: entrypoint: and command:.

Exec form and shell form

Both instructions accept two spellings. The JSON list is the exec form, and a plain string is the shell form. They are not equivalent. The shell form is wrapped in /bin/sh -c, so the shell becomes the main process and your program runs under it. This matters when you stop the container, because Docker sends the stop signal to process 1 only.

Build the same small Python program twice. It prints a message when it receives the stop signal. Only the last line of the Dockerfile differs.

import signal, sys, time

def bye(signum, frame):
    print("got SIGTERM, shutting down", flush=True)
    sys.exit(0)

signal.signal(signal.SIGTERM, bye)
print("ready", flush=True)
time.sleep(300)
FROM python:3.14-alpine
COPY app.py /app.py
CMD python /app.py; echo app exited
FROM python:3.14-alpine
COPY app.py /app.py
CMD ["python", "/app.py"]
Set-Location ..\sig-demo
docker build -q -f Dockerfile.shell -t sig-demo:shell . | Out-Null
docker build -q -f Dockerfile.exec -t sig-demo:exec . | Out-Null
docker run -d --name sig-shell sig-demo:shell | Out-Null
docker run -d --name sig-exec sig-demo:exec | Out-Null
Start-Sleep -Seconds 2
docker exec sig-shell ps -o pid,args
docker exec sig-exec ps -o pid,args
PID   COMMAND
    1 /bin/sh -c python /app.py; echo app exited
    7 python /app.py
    8 ps -o pid,args
PID   COMMAND
    1 python /app.py
    7 ps -o pid,args

In the shell version, process 1 is /bin/sh -c python /app.py; echo app exited, and Python is only its child. In the exec version, Python is process 1. Now stop both with a three-second timeout and compare.

docker stop --timeout 3 sig-shell sig-exec | Out-Null
"shell: exit code " + (docker inspect --format "{{.State.ExitCode}}" sig-shell)
docker logs sig-shell
"exec: exit code " + (docker inspect --format "{{.State.ExitCode}}" sig-exec)
docker logs sig-exec
shell: exit code 137
ready
exec: exit code 0
ready
got SIGTERM, shutting down

The exec version heard the signal, printed its message and exited with code 0 at once. The shell version never got the signal. Docker waited the three seconds, then killed it, which is exit code 137, and the message never appeared. With the default timeout of ten seconds, every docker stop of that container would take ten seconds and end in a kill, and anything the program meant to do on shutdown, like closing connections or saving state, is skipped.

💡 Tip: Use the exec form for ENTRYPOINT and CMD. Use the shell form only when you need shell features such as variable expansion on purpose, and then start the program with exec so it replaces the shell.

One detail so this does not surprise you. The shell in this Alpine image replaces itself with the last command on the line, so CMD sleep 300 and even CMD echo starting && python /app.py look fine: I checked, and process 1 was the program. The shell stays in charge when the program is not the last command, as in the example above. If you cannot change the command, docker run --init adds a tiny process 1 that passes signals on.

A real entrypoint script

Many official images use an entrypoint script that prepares something, then hands over. The last line must be exec "$@", which replaces the script with the command.

#!/bin/sh
echo "entrypoint: preparing"
exec "$@"
FROM alpine:3.24
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["echo", "default command"]
Set-Location ..\script-demo
docker build -q -t script-demo:1 . | Out-Null
docker run --rm script-demo:1
docker run --rm script-demo:1 echo "my own command"
entrypoint: preparing
default command
entrypoint: preparing
my own command

The script ran both times, and the command that follows it changed. Because of exec, the command is process 1 and receives the stop signal. Leave exec out and the script stays in charge and swallows the signal, which is the shell-form problem again.

A Windows trap: line endings

If you edit entrypoint.sh in an editor that saves Windows line endings (CRLF), the first line becomes #!/bin/sh followed by a carriage return. Linux then looks for an interpreter called sh\r and cannot find it. Here is the real error, made by writing the script with CRLF on purpose.

[IO.File]::WriteAllText("$PWD\entrypoint.sh", "#!/bin/sh`r`necho hi`r`nexec ""`$@""`r`n")
docker build -q -t script-demo:2 . | Out-Null
docker run --rm script-demo:2
exec /entrypoint.sh: no such file or directory

"No such file or directory" for a file that is plainly there is the sign. Save shell scripts with LF line endings, and add a .gitattributes line such as *.sh text eol=lf so Git does not change them back on Windows.

⚠ Warning: A script that is not executable gives a different error, "permission denied". The RUN chmod +x line in the Dockerfile avoids it, and it works whatever the file permissions were on your computer.

Which one do you use more often in your own Dockerfiles, CMD or ENTRYPOINT, and would you notice if the container stopped passing your arguments through?

About

Run the same small image with CMD, with ENTRYPOINT, in exec form and in shell form, and see which one receives the stop signal. After this, you can read any Dockerfile ending and know what runs, what you can replace and why a container will not stop.

Duration
10 min
Last update
08/10/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.