skip to content

Describe the entrypoint wrapper-script pattern used by images such as the official Postgres image: what work belongs in that script, how it is wired up in the Dockerfile, and why the script must end with `exec "$@"`?

level: seniorimportance: should knowfreq 40%

answer

  1. ENTRYPOINT script + CMD real command
  2. runtime-only work; build-time work stays in layers
  3. exec = keep PID 1
  4. quoted "$@" preserves argument boundaries
  5. gosu/su-exec to drop privileges, not su

basics

~20 s

Set ENTRYPOINT to a small script and CMD to the real command. The script does start-up work — render config, fix permissions, first-run initialisation — then runs exec "$@", replacing itself with the CMD so the real process becomes PID 1 and receives signals.

solid answer

~50 s

The pattern is `ENTRYPOINT ["/docker-entrypoint.sh"]` plus `CMD ["postgres"]`. Every container start runs the script; the script ends by handing off to whatever CMD (or the user's `docker run` arguments) holds. What belongs in it: turning environment variables into config files, first-run initialisation guarded by a marker on the data volume, fixing ownership of mounted volumes, waiting for or validating required variables, and dropping privileges with `gosu`/`su-exec` if it must start as root. What does not: anything that could be done at build time (that belongs in a layer), and long-running supervision of multiple processes. `exec "$@"` is load-bearing for two reasons. `exec` replaces the shell process image, so the application inherits PID 1 and gets SIGTERM from `docker stop` directly. `"$@"` — quoted — expands the script's arguments preserving word boundaries, which is exactly the CMD or the user's overrides. Without `exec` the shell lingers as PID 1 and swallows signals; without quotes, arguments containing spaces are split.

code

bash · 20 lines
bash
#!/bin/sh
set -eu

if [ "${1#-}" != "$1" ]; then
  set -- postgres "$@"
fi

if [ "$1" = 'postgres' ]; then
  : "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}"
  if [ ! -s "$PGDATA/PG_VERSION" ]; then
    initdb -D "$PGDATA"
    for f in /docker-entrypoint-initdb.d/*.sql; do
      [ -e "$f" ] && psql -f "$f"
    done
  fi
  chown -R postgres "$PGDATA"
  exec gosu postgres "$@"
fi

exec "$@"

go deeper

for a junior

Recognise the pattern — ENTRYPOINT is a script, CMD is the real command — and know the script hands off with exec "$@".

for a middle

Explain what work is runtime-only versus build-time, the marker-file idempotency trick, and precisely why both exec and the quoting matter.

for a senior

Discuss privilege drop with gosu, fail-fast validation, permission repair on mounts, and the operational traps: chmod, CRLF shebangs, set -e.

for a principal

Weigh the pattern against alternatives — config baked at deploy time, init containers, sidecars — and set a house rule for how much logic may live before observability attaches.

## The shape of the pattern ``` COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/ ENTRYPOINT ["docker-entrypoint.sh"] CMD ["postgres"] ``` Because the runtime concatenates Entrypoint and Cmd, the container's actual argument vector is `docker-entrypoint.sh postgres`. The script therefore receives the intended command as its positional parameters — and if a user runs `docker run img postgres -c max_connections=200`, it receives that instead. The script is a *decorator* around an unknown command, not a replacement for it. ## Why not just do the work in the Dockerfile? Because it depends on runtime inputs that do not exist at build time: environment variables, mounted volumes, secrets, the identity of the host. A config file rendered at build time bakes one environment into the image and breaks the promise that the same artifact ships to every stage. Anything that *is* known at build time — installing packages, compiling, copying static config — belongs in a layer, where it is cached and auditable. A wrapper script that installs packages on every boot is a defect: it slows start-up, needs network at run time, and makes containers non-identical. ## What legitimately belongs in the script - **Config templating.** Substitute environment variables into a config file (`envsubst`, `sed`, or the app's own templating) before the app reads it. - **First-run initialisation.** Databases use a marker file on the data volume: if it is absent, create the cluster, run the seed scripts in `/docker-entrypoint-initdb.d`, then continue; if present, skip straight to start-up. This makes the container idempotent across restarts. - **Volume permission repair.** A bind-mounted host directory arrives with host ownership; the script `chown`s it to the runtime user before dropping privileges. - **Privilege drop.** If the setup genuinely needs root, finish with `exec gosu appuser "$@"` (or `su-exec` on Alpine) instead of `su`/`sudo`, which fork and reintroduce an intermediate PID 1. - **Fail-fast validation.** Check required variables and exit non-zero with a clear message rather than letting the app fail obscurely three seconds later. - **Convenience dispatch.** Many official images check whether the first argument starts with a `-` and, if so, prepend the real binary — so `docker run postgres -c fsync=off` works. Some also pass anything that is not the expected command straight through, so `docker run img bash` still gets you a shell. ## Why `exec "$@"` specifically **`exec`** replaces the shell's process image with the new program, keeping the same PID. Since the script started as PID 1, the application becomes PID 1. That preserves the whole shutdown path: `docker stop` sends SIGTERM to PID 1, the app's handler drains connections and exits, the container stops in milliseconds. Drop the `exec` and the shell stays as PID 1 with the app as its child; the shell does not forward SIGTERM, so every stop takes the full grace period and ends in SIGKILL — dropped requests, corrupt shutdown, ten seconds added to every deploy. **`"$@"`** expands to the script's positional parameters as separate, correctly-quoted words. `$*` collapses them into one string; unquoted `$@` word-splits on spaces, so an argument like `--message=hello world` breaks apart. Only the quoted form round-trips arbitrary arguments faithfully — which is the entire job of a wrapper. ## Operational details that bite - The script must be executable. Prefer `COPY --chmod=755` over a separate `RUN chmod +x`; a non-executable script yields exit code 126. - Line endings must be LF. A script committed from Windows with CRLF has a shebang of `#!/bin/sh\r`, and the kernel reports `no such file or directory` for a file that visibly exists — one of the most confusing container errors there is. - Start the script with `set -e` (and often `set -u`) so a failing setup step aborts the container instead of starting the app in a half-configured state. - Reference the script by a path on `PATH` or an absolute path; a bare relative name interacts badly with WORKDIR changes. - Keep it short and readable. An entrypoint script is code that runs before every observability tool attaches; complexity there is expensive to debug. ## Trade-offs The pattern costs debuggability — inspecting the image now needs `--entrypoint sh` — and adds a place where behaviour hides from the Dockerfile. It buys runtime configurability, idempotent initialisation and a clean single-process shutdown story. For a service image with any first-run or config-rendering needs, that trade is usually right; for a self-contained static binary that reads its own environment, an entrypoint script is pure overhead.

  • What breaks if the script ends with `"$@"` instead of `exec "$@"`?
    The shell remains PID 1 and the application runs as its child. `docker stop` sends SIGTERM to the shell, which does not forward it, so the app never runs its shutdown logic and is SIGKILLed after the grace period. You also keep an extra process around that must reap the child correctly.
  • Why `"$@"` rather than `$*` or unquoted `$@`?
    Quoted `"$@"` expands each positional parameter as its own word with quoting preserved. `$*` joins them into a single string, and unquoted `$@` re-splits on whitespace, so any argument containing spaces is mangled. A wrapper must pass arguments through byte-for-byte.
  • The script needs root to fix volume ownership, but the app must not run as root. How do you handle that?
    Do the root-only work first, then hand off with `exec gosu appuser "$@"` (or `su-exec` on Alpine). Those tools exec directly into the target user without forking, so the application still becomes PID 1. `su` and `sudo` fork and leave an intermediate process holding PID 1, reintroducing the signal problem.

The script is a stagehand: it sets up the scene, then walks off and lets the actor take the stage alone — rather than standing in front of the actor for the whole performance.

saying these in an interview costs you the question

  • Installing packages or building artifacts inside the entrypoint script instead of in build layers.
  • Ending the script with a plain call to the command, losing PID 1 and signal delivery.
  • Using `$@` unquoted or `$*`, silently mangling arguments that contain spaces.
  • Using `su`/`sudo` to drop privileges, which forks and leaves the shell as PID 1.
  • Assuming the script runs once per image build rather than on every container start.

context