An image’s ENTRYPOINT and CMD contribute to the command used when a container is created. With an exec-form ENTRYPOINT, CMD commonly supplies default arguments appended to that entrypoint. Without an entrypoint, CMD can supply the executable and its arguments. These are startup defaults, not an enforcement boundary: an authorized operator can replace the entrypoint or arguments when creating a container.

When it breaks

Trailing arguments in docker run image ... replace CMD; they are not automatically added after CMD’s existing defaults. The exec-form entrypoint remains unless explicitly overridden. A user expecting to append one flag can accidentally discard required defaults, so command-line tools should document which values are optional, which must be repeated, and how their own argument parser treats overrides.

Exec form uses a JSON array and does not implicitly run a shell. A string such as $DEMO reaches the program literally instead of receiving shell variable expansion. Pipes, redirects, and wildcards likewise need an actual interpreter if that is the intended behavior. Shell form changes argument and process semantics, so switching forms is more than a cosmetic syntax choice.

A startup wrapper can also hide the service behind a shell that never forwards the container’s stop signal. Docker normally signals the main container process, waits for the configured timeout, then forces termination if necessary. A service’s correct SIGTERM handler is ineffective if the signal is delivered only to another process that does not pass it on.

Check it quickly

Run this script with host Python and access to a Docker builder after obtaining the pinned base. It was executed with Docker 29.3.1 and Linux arm64 Python 3.11.16. The fixture creates isolated temporary containers with networking disabled, waits for each service’s readiness output, tests stopping, and removes its containers and image tag:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,time,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def inspect(name):return json.loads(docker('inspect',name).stdout)[0]

name='xbcatm-entrypoint-'+uuid.uuid4().hex[:10];tag=name+':probe';containers=[name+'-exec',name+'-child']
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory)
        (root/'args.py').write_text('import json,sys\nprint(json.dumps(sys.argv[1:]))\n')
        (root/'service.py').write_text('import json,os,signal\ndef stop(signum,frame):\n print("stopped",flush=True)\n raise SystemExit(0)\nsignal.signal(signal.SIGTERM,stop)\nprint(json.dumps({"pid":os.getpid()}),flush=True)\nwhile True:signal.pause()\n')
        (root/'exec.sh').write_text('#!/bin/sh\nexec python /service.py "$@"\n')
        (root/'child.sh').write_text('#!/bin/sh\npython /service.py "$@" &\nwait $!\n')
        (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY args.py service.py exec.sh child.sh /\nRUN chmod +x /exec.sh /child.sh\nENV DEMO=expanded\nENTRYPOINT ["python", "/args.py"]\nCMD ["default", "$DEMO"]\n')
        docker('build','--network','none','-t',tag,str(root))
        assert json.loads(docker('run','--rm','--network','none',tag).stdout)==['default','$DEMO']
        assert json.loads(docker('run','--rm','--network','none',tag,'changed').stdout)==['changed']
        assert docker('run','--rm','--network','none','--entrypoint','python',tag,'-c','print("override")').stdout.strip()=='override'
        assert docker('run','--rm','--network','none','--entrypoint','/bin/sh',tag,'-c','printf "%s" "$DEMO"').stdout=='expanded'
        print('CMD supplies literal default arguments; trailing arguments replace it; --entrypoint replaces the executable')
        for container,wrapper in zip(containers,['/exec.sh','/child.sh']):
            docker('run','-d','--name',container,'--network','none','--entrypoint',wrapper,tag)
            deadline=time.monotonic()+10
            while time.monotonic()<deadline:
                logs=docker('logs',container).stdout
                if logs.strip():break
                time.sleep(.05)
            else:raise AssertionError('service did not become ready')
            pid=json.loads(logs.splitlines()[0])['pid']
            assert (pid==1)==(wrapper=='/exec.sh')
            docker('stop','--time','1',container)
            state=inspect(container)['State'];logs=docker('logs',container).stdout
            if wrapper=='/exec.sh':assert state['ExitCode']==0 and 'stopped' in logs
            else:assert state['ExitCode']==137 and 'stopped' not in logs
        print('exec makes the handler PID 1 and stops cleanly; the non-forwarding shell wrapper leaves its child unnotified until forced termination')
finally:
    docker('rm','-f',*containers,check=False);docker('image','rm',tag,check=False)

The first image command prints its received argument list. With no override it receives default and the literal $DEMO, even though the image defines DEMO as expanded. Supplying changed after the image name replaces both default arguments, and the program receives exactly one argument. These assertions inspect the final argument vector rather than guessing from Dockerfile syntax.

Using --entrypoint python and explicit Python arguments runs an entirely different command that prints override. Using /bin/sh -c explicitly expands $DEMO and prints expanded. Together, these cases show both that ENTRYPOINT is overridable and that expansion belongs to the invoked shell, not to a hidden expansion step in exec-form command construction.

The second part compares two wrapper scripts launching the same service. The exec wrapper replaces itself with Python, which reports PID 1 inside the container. Its installed SIGTERM handler prints stopped and exits with status zero when Docker stops the container. The fixture verifies the message and the recorded exit status.

The other wrapper starts Python as a child and waits, without forwarding signals. The service reports a PID other than one. Stopping that container with a one-second timeout produces exit status 137 in the recorded environment, and the service never prints stopped. The forced termination is observed directly; no conclusion is inferred merely from the presence of a shell script.

PID 1 has special signal and child-reaping responsibilities in a Linux PID namespace. An exec wrapper makes the service the signal recipient, but it does not automatically implement application cleanup, drain in-flight work, or reap arbitrary descendants. The service still needs appropriate behavior, or a deliberately chosen init/supervision arrangement when its process model requires one.

Treat startup and shutdown as one interface

Use valid JSON arrays with double-quoted strings for exec-form instructions. Keep the executable and stable arguments clear, and document how callers replace defaults. If initialization requires a shell wrapper, finish with an appropriate exec when the wrapper’s job is to hand control to one foreground service. Quote forwarded arguments so their boundaries remain intact.

Test the actual deployed launch path, including any orchestration overrides. A correct image entrypoint can be replaced by a configuration that introduces another shell, and an overridden command may omit necessary initialization. Inspect the resulting process tree and exercise termination with representative active work.

Choose the stop signal and grace period according to the service’s contract. Record readiness, successful cleanup, and exit status separately. A container that eventually disappears after SIGKILL has stopped, but that observation alone does not prove requests completed or buffered state was safely handled.

Sources