Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Docker Entrypoints: Preserve Arguments and Deliver Stop Signals

Choose Docker CMD and ENTRYPOINT forms deliberately, preserve argv through wrapper scripts, and verify container shutdown without hiding the real process.

A container’s ENTRYPOINT and CMD define a process interface, not merely a line of shell text. Docker supports exec and shell forms, and those forms have different argument, expansion, and signal behavior. A service that works when started interactively can still ignore docker stop, lose runtime arguments, or leave a shell as its main process if the Dockerfile mixes the forms without understanding how they compose.

For a single long-running executable, use an exec-form ENTRYPOINT and usually an exec-form CMD for overridable defaults. Add a small entrypoint script only when startup work is genuinely needed, and finish it with exec so the actual service becomes the container’s main process. If the container intentionally supervises multiple daemons, use an init or supervisor designed for that job rather than assuming a shell automatically reaps every child correctly.

Read exec form as an argument vector

Exec form is JSON array syntax:

ENTRYPOINT ["/usr/local/bin/worker"]
CMD ["--config", "/etc/worker/worker.toml", "--foreground"]

Docker launches the executable directly; it does not automatically insert /bin/sh -c. When an image uses an exec-form entrypoint, arguments supplied after the image name are appended to the entrypoint’s configured arguments and can override the image’s CMD defaults. This is the useful model for an image that behaves like a command-line program with documented defaults and user-supplied options.

Exec form does not perform shell variable expansion. A value such as "$PORT" is passed as the literal characters $PORT; it is not expanded by the Dockerfile builder or by a shell that was never started. If expansion is required, decide where that expansion belongs. Prefer an application option or a short entrypoint script with quoted argv. Use an explicit shell only when shell language features are really needed, and do not turn an untrusted value into shell source.

JSON quoting is not shell quoting. Backslashes and quotes must form valid JSON string escapes, and each array item is one argv element. A space inside an item remains inside one argument; separate array elements remain separate arguments. This explicit representation is easier to reason about than a command string that has to be split and interpreted by multiple layers.

Know what shell form changes

Shell form writes a command as plain Dockerfile text:

ENTRYPOINT /usr/local/bin/worker --config /etc/worker/worker.toml

On Linux, Docker runs shell-form commands through /bin/sh -c by default. That shell performs normal shell processing, but the extra shell affects the process tree and arguments. For ENTRYPOINT, Docker documents that shell form ignores CMD and docker run arguments. The executable is not directly PID 1, and Unix signals sent by docker stop do not automatically reach the child as if it were the main process.

CMD and ENTRYPOINT combinations are not interchangeable. If an exec-form entrypoint is paired with shell-form CMD, the command text can arrive as arguments in an unexpected /bin/sh -c form. Use exec form for both when the entrypoint is an executable with default arguments. Consult Docker’s combination table instead of reasoning from how a terminal would parse the words.

The Dockerfile SHELL instruction changes the shell used for shell-form RUN, CMD, and ENTRYPOINT instructions that follow it. It does not convert exec form into shell form. This is particularly relevant on Windows images, where cmd and PowerShell are different command processors with different quoting rules. Make the runtime shell choice explicit and test on the target base image; a Linux example using /bin/sh does not transfer unchanged to a Windows container.

Keep build-time and run-time interpretation separate. RUN executes while building a layer, while CMD and ENTRYPOINT describe the default process when a container starts. A successful RUN command says nothing about the runtime’s argument vector or shutdown path. Avoid assuming that a shell variable set in one Dockerfile instruction will survive into a later instruction as shell state; each instruction runs in its own build step, while persisted environment configuration is represented through image metadata such as ENV. Runtime configuration should be passed through a deliberate interface and validated by the entrypoint or application.

Write wrapper scripts as argv-preserving adapters

A wrapper is useful for checking required settings, preparing directories, applying permissions, or selecting a mode before starting one program. The final call should preserve the argument vector rather than concatenate it into a string:

#!/bin/sh
set -eu

if [ "${WORKER_MODE:-}" = "maintenance" ]; then
    /usr/local/bin/worker --check-config
fi

exec /usr/local/bin/worker "$@"

Use the script as an exec-form entrypoint:

COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint
RUN chmod 0755 /usr/local/bin/docker-entrypoint
ENTRYPOINT ["/usr/local/bin/docker-entrypoint"]
CMD ["--foreground"]

"$@" expands to one word per original argument, preserving spaces and empty arguments. Do not replace it with $*, an unquoted $@, or eval "... $*"; those forms can destroy boundaries and make data become syntax. If the wrapper has optional flags, parse them into a deliberate argv sequence and then use exec with quoted arguments.

The check in the sample can be expanded only if its behavior is meaningful and safe on every container start. Avoid expensive migrations or network-dependent initialization in a wrapper unless startup retries and partial state are designed. When the preflight fails, propagate a nonzero result so the orchestrator can report failure. Do not print secrets from environment variables as part of a debugging shortcut.

Signal forwarding and multiple children

When the shell process is replaced by exec, the target executable becomes the process that Docker tracks as the container’s main process. This removes one forwarding hop and allows the normal stop signal to reach the service directly. STOPSIGNAL can set which signal Docker sends, but it cannot make an application handle a signal it ignores. The process still needs to close connections, flush data, stop workers, and exit within the runtime’s configured grace period.

If a wrapper must remain alive to coordinate more than one child, install traps, forward the intended signal to known child processes, wait for them, and preserve the desired exit status. That is a supervisor problem, not merely a shell-form choice. Shells differ in job-control and signal behavior, and a PID list does not necessarily represent every descendant process. For production multi-process containers, use an init or process supervisor with explicit reaping and shutdown policy.

Avoid launching a daemon into the background and then exiting the entrypoint. The container can stop when its main process exits even though the intended service was launched incorrectly, or it can remain alive with a shell that never reports the child’s termination. Keep the application in the foreground and make its status the container status.

Verify the image’s process contract

Inspect the built image configuration with docker image inspect and confirm Entrypoint and Cmd are the forms you intended. Start the image with no extra arguments, with a replacement argument, and with an explicit --entrypoint override in a disposable environment. Use a benign command that prints each argument with delimiters when validating whitespace boundaries. Check docker top or an equivalent process view to confirm whether the expected binary is PID 1 and whether any wrapper remains in the process tree.

Test shutdown as an integration behavior. Start a workload that handles the configured signal, run docker stop with a measured timeout, and inspect the container’s exit status and logs. A passing image build says nothing about stop behavior. Also test the failure path when a required environment setting is absent, a config file is unreadable, or a preflight command fails. The resulting exit code should identify the problem without leaking credentials.

Docker’s stop request has a grace period. The daemon sends the configured stop signal and, if the container has not exited before the timeout, sends a kill signal. A process that flushes data or completes transactions may need a longer grace period, but increasing that value cannot fix a process that never receives the initial signal. Measure shutdown with representative workload and confirm the application actually exits. Test both a clean stop and a process that exceeds the deadline so operations know whether termination can become abrupt.

Do not use a health check as a substitute for process supervision. Health status is useful for detecting an application that is alive but not ready or responsive; it does not automatically make a shell wrapper forward signals, preserve arguments, or reap child processes. Keep the health command independent from the entrypoint where possible, ensure it uses the same configuration contract, and avoid a health check that mutates production state. A container may be healthy at startup and still have an invalid shutdown path, so readiness and lifecycle tests belong in separate checks.

Also test the effective image after inheritance. A base image may define its own ENTRYPOINT, CMD, or SHELL, and Dockerfile instructions can replace or combine these values according to their documented rules. Inspect the final image rather than reviewing only the last Dockerfile fragment. When extending a vendor image, write down whether your image intentionally replaces its entrypoint or relies on its arguments; an accidental override can discard initialization behavior the upstream image requires.

Keep the entrypoint focused. Docker’s exec form gives the clearest default for process identity and argument boundaries; shell form is a conscious choice for shell language; a wrapper script is the right place for a small number of startup checks. When the script begins to own retries, dependency ordering, health supervision, and several child lifecycles, move that responsibility to an orchestrator or supervisor that exposes those controls directly.

Related:

Sources:

Comments