Docker Deep Dive · Prerequisites · P7 of 7

Shell Scripts & Entrypoints

Turn commands into programs — then write the ten-line script that every official image ships, and prove with a stopwatch why its one strange line, exec "$@", decides whether your app shuts down in 0.2 seconds or gets killed after 10.

⏱ ~30 min hands-on · repo chapter p7-shell-scripts-and-entrypoints · cheatsheet: Linux & Shell

prereq P7 / 7

Before you start

cd docker-deep-dive
git pull
cd p7-shell-scripts-and-entrypoints

Labs A–B happen inside a throwaway Ubuntu container (one command, given below). Lab C builds a tiny image called p7lab from this folder — docker build is taught properly in Lesson 3; if you're doing the prerequisites first, just paste and trust it for now.

1.A script is a file the kernel can run

Everything you've typed in P1–P6 vanished when you pressed Enter. A script is those same commands saved in a file — and three things you already own turn that file into a first-class program. The #! (shebang) line is read by the kernel, not the shell: during execve() — P5's one door — the kernel peeks at the file's first bytes, sees #!, and launches the named interpreter with your file as its argument.¹ The execute bit is P2's chmod +x. And ./greet.sh instead of greet.sh is P4's PATH rule — your current directory isn't on the hunt list, so you give an explicit path.

./greet.sh captain shell calls execve() kernel peeks at byte 0–1 #! → “this file names its own interpreter” runs: /usr/bin/env bash greet.sh captain needs the x-bit (P2) — without it, execve fails: “Permission denied”, exit 126 (P4)
The shebang is a kernel feature. #!/usr/bin/env bash asks env to find bash on the PATH — the portable spelling you'll see in real entrypoints.¹

Lab A — first script, and the permission loop that finally makes sense

docker run -it --rm ubuntu:24.04 bash
cat > greet.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
name="${1:?usage: greet.sh NAME}"
echo "hello, ${name}! (argument 1 of $#)"
EOF
./greet.sh captain
echo $?
bash: ./greet.sh: Permission denied
126

Exit 126 — P4's table said "found but not executable", and now you've manufactured one on purpose. Fix it with P2's tool and run the three interesting cases:

chmod +x greet.sh
./greet.sh captain
./greet.sh
echo $?
hello, captain! (argument 1 of 1)
./greet.sh: line 3: 1: usage: greet.sh NAME
1

$1 is the first argument, $# counts them, and ${1:?message} is a guard: if argument 1 is missing, print the message and die with exit 1.² Your script now validates its inputs and fails loudly — already better than most scripts in production.

2.Variables, tests, loops — and the exit-code truth

Two rules carry you a long way. Quote everything: "$var" survives spaces and empty values; bare $var is a bug waiting for a filename with a space in it.³ And the deeper one: if doesn't test truth — it runs a command and branches on its exit code (P4's currency). [ is just a command that exits 0 or 1. So is grep -q. So is docker.

testexit 0 (“true”) when
[ -f "$p" ] / [ -d "$p" ]file / directory exists
[ -z "$v" ] / [ -n "$v" ]string empty / non-empty
[ "$a" = "$b" ]strings equal — the spaces around [, =, ] are mandatory
grep -q PATTERN fpattern found (quietly) — any command works here
if grep -q "24.04" /etc/os-release; then echo "noble detected"; fi

grep -q bookworm /etc/os-release && echo "debian" || echo "not debian"

set -- red green blue        # fake three script arguments
for c in "$@"; do echo "processing: $c"; done
noble detected
not debian
processing: red
processing: green
processing: blue

a && b runs b only if a succeeded; a || b only if it failed. You've been reading this pattern since Lesson 3 — every multi-command Dockerfile RUN line is a tiny &&-script, and now you know why it's chained that way: one failure must fail the whole line, or the build lies to you.

3.The safety net: set -euo pipefail

Here's the disaster mode. Write a deploy script with two bugs — a cp from a file that doesn't exist, and a typo'd variable — and run it bare:

cat > deploy.sh <<'EOF'
#!/usr/bin/env bash
cp /etc/app.conf ./app.conf
echo "starting deploy for env: $TARGET_ENV"
echo "deploy complete ✓"
EOF
chmod +x deploy.sh
./deploy.sh
echo $?
cp: cannot stat '/etc/app.conf': No such file or directory
starting deploy for env:
deploy complete ✓
0

Read that carefully: the copy failed, the env name printed as nothing, and the script still announced success with exit 0. By default the shell shrugs at failed commands and treats unset variables as empty strings. Now add the net — one line, three switches:⁴

sed -i '2i set -euo pipefail' deploy.sh
./deploy.sh
echo $?
cp: cannot stat '/etc/app.conf': No such file or directory
1

-e stopped the script at the first failing command. Fix the cp (point it at a file that exists) and -u catches the next bug:

sed -i 's|/etc/app.conf|/etc/os-release|' deploy.sh
./deploy.sh
echo $?
./deploy.sh: line 4: TARGET_ENV: unbound variable
1

And pipefail plugs P3's blind spot: a pipeline's exit code is normally the last command's, so a dead first stage hides behind a healthy wc:

bash -c 'grep boom /etc/os-release | wc -l; echo exit=$?'
bash -c 'set -o pipefail; grep boom /etc/os-release | wc -l; echo exit=$?'
0
exit=0
0
exit=1

Why Dockerfiles care

A RUN line executes under /bin/sh -c with no safety net. If you write RUN apt-get update; apt-get install -y curl and the update fails, the install still runs against a stale index — and the build succeeds, baking the failure into your image. That's why real Dockerfiles chain with &&, and why real entrypoints open with set -e. When debugging, add set -x to echo each command as it runs.³

4.Portable shell — /bin/sh is not bash

That safety net you just wrote hides a catch: set -o pipefail is a bash feature, and the shell that actually runs your scripts often isn't bash. On Debian and Ubuntu, /bin/sh is dash; on Alpine — the base of half the images you'll ship — it's busybox. Neither is bash:

docker run --rm ubuntu:24.04 readlink -f /bin/sh
docker run --rm alpine       readlink -f /bin/sh
/usr/bin/dash
/bin/busybox

The features bash adds beyond the POSIX standard — "bashisms" — simply fail under /bin/sh. Watch three common ones break under dash, including the very pipefail you just met:

docker run --rm ubuntu:24.04 bash -c '
sh -c "[[ 1 = 1 ]]"        # bash test brackets
sh -c "a=(1 2 3)"          # arrays
sh -c "set -o pipefail"    # the §3 safety-net option
'
sh: 1: [[: not found
sh: 1: Syntax error: "(" unexpected
sh: 1: set: Illegal option -o pipefail

The sharpest version bites the instant you base an image on Alpine: it ships no bash at all, so a script that starts with #!/bin/bash won't even launch —

docker run --rm alpine bash -c 'echo hi'
exec: "bash": executable file not found in $PATH

Two honest choices, and a rule for picking:

you want…do this
bash features (arrays, [[ ]], pipefail)say #!/bin/bash — and make sure bash is installed (apk add bash on Alpine)
max portability / speed (system & Alpine scripts)say #!/bin/sh and use POSIX only: [ ] not [[ ]], command -v not which, no arrays, no pipefail

And the tool that keeps you honest is shellcheck (toolbox lesson T4, later in this course, makes it a daily driver): it flags bashisms in a #!/bin/sh script automatically — shellcheck --shell=sh yourscript. Pick your shebang deliberately, then let ShellCheck enforce it.⁹

5.Who hears docker stop?

Now the capstone. You know from P4 that docker stop sends SIGTERM to PID 1 and only PID 1, waits ~10 seconds, then SIGKILLs. So everything hinges on which process is PID 1 — and that is decided by one word in your entrypoint script. Step through it:

A · entrypoint WITHOUT exec PID 1 · bash entrypoint-noexec.sh no TERM trap · PID-1 immunity (P4) PID 7 · worker.sh (child) docker stop → SIGTERM ignored — nothing forwards it down ⏱ 1 s … 5 s … 10 s worker: heartbeat 13 worker: heartbeat 14 … grace over → SIGKILL the whole tree → exit 137, no cleanup B · entrypoint WITH exec "$@" PID 1 · bash worker.sh the shell was REPLACED — no wrapper left trap 'running=false' TERM docker stop → SIGTERM trap fires → “finishing current item, bye” clean exit 0 — in 0.17 s, not 10 0.17 s vs 10.19 s · exit 0 vs 137 — the difference is one word: exec
  1. The broken shape: the entrypoint shell runs the worker as a child, so bash — not your app — is PID 1.
  2. docker stop delivers SIGTERM to PID 1 only. Bash has no TERM trap, and PID 1 ignores default signal actions (P4).
  3. The grace period ticks. The worker keeps heartbeating — nobody ever told it to stop.
  4. At ~10 s the daemon gives up: SIGKILL takes down the tree. Exit 137 = 128 + 9. No cleanup ran.
  5. The fix: exec "$@" replaces the shell with the app — same PID, new program. Your worker IS PID 1.
  6. Now SIGTERM lands on the process that has the trap. It finishes its current item and says goodbye.
  7. Clean exit 0 in a fifth of a second. One word in one script decides which timeline you live in.
Signals don't trickle down. docker stop talks to PID 1 and nobody else — exec is how your app gets that seat. Kubernetes plays the same game at pod termination, just with a 30-second default grace.⁵

6.Lab C — the showdown, timed on your machine

The chapter folder has both entrypoints, the worker, and a Dockerfile that wires the good one in. Build and run the healthy version:

docker build -t p7lab .
docker run -d --name p7-good p7lab
docker logs p7-good | head -3
docker exec p7-good ps -ef
entrypoint: preparing config: PORT=8000
worker: started (pid 1)
worker: heartbeat 1

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 03:18 ?        00:00:00 bash /worker.sh
root        11     1  0 03:18 ?        00:00:00 sleep 1
root        12     0  0 03:18 ?        00:00:00 ps -ef
docker logs p7-good | Select-Object -First 3

Look at what exec did: the entrypoint printed its prep line and then vanished — no shell in the process list, the worker sits at PID 1. Now the stopwatch:

time docker stop p7-good
docker logs --tail 2 p7-good
docker inspect --format '{{.State.ExitCode}}' p7-good
docker rm p7-good
docker stop p7-good  0.06s user 0.03s system 53% cpu 0.173 total
worker: heartbeat 4
worker: SIGTERM — finishing current item, bye
0
Measure-Command { docker stop p7-good }

Now the broken twin — same image, same worker, but the no-exec entrypoint swapped in with --entrypoint:

docker run -d --name p7-bad --entrypoint /entrypoint-noexec.sh p7lab /worker.sh
docker exec p7-bad ps -ef
time docker stop p7-bad
docker inspect --format '{{.State.ExitCode}}' p7-bad
docker logs --tail 2 p7-bad
docker rm p7-bad
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 03:18 ?        00:00:00 bash /entrypoint-noexec.sh /worker.sh
root         7     1  0 03:18 ?        00:00:00 bash /worker.sh
root        11     7  0 03:18 ?        00:00:00 sleep 1
root        12     0  0 03:18 ?        00:00:00 ps -ef

docker stop p7-bad  0.06s user 0.03s system 0% cpu 10.191 total
137
worker: heartbeat 13
worker: heartbeat 14
Measure-Command { docker stop p7-bad }
entrypointworker PIDdocker stop tookexit codegoodbye line?
exec "$@"10.173 s0yes — trap ran
plain "$@"7 (child)10.191 s137no — killed mid-heartbeat

Exit 137, two flavors

Lesson 10's OOM kill and this grace-period kill both end in 137 — it always means 128 + 9, death by SIGKILL. docker inspect's .State.OOMKilled tells you which story you're in. A slow-stopping container in production is this lesson's bug far more often than a memory one.

One more run pays off two earlier lessons at once — pass an env var (P4's inheritance, Lesson 4's -e) and replace CMD with your own command:

docker run --rm -e PORT=9000 p7lab echo done
entrypoint: preparing config: PORT=9000
done

The entrypoint read ${PORT:-8000} and your echo done arrived as "$@", replacing the default worker. That's the whole contract, observed.

7.The pros do exactly this

You already have the world's most-pulled database image cached from Lesson 6. Look inside its entrypoint:⁶

docker run --rm postgres:16-alpine head -n 2 /usr/local/bin/docker-entrypoint.sh
docker run --rm postgres:16-alpine grep -n 'exec "$@"' /usr/local/bin/docker-entrypoint.sh
#!/usr/bin/env bash
set -Eeo pipefail
377:	exec "$@"

Shebang, safety net, and — 377 lines of initialization later — the same exec "$@" you wrote today. Postgres prepares databases, users, and config, then hands PID 1 to the real server so shutdown signals reach it. The formal contract between the two Dockerfile instructions:⁷

instructionroleoverridden by
ENTRYPOINT ["/entrypoint.sh"]the program that always runsdocker run --entrypoint …
CMD ["/worker.sh"]default arguments to it — your entrypoint's "$@"anything after the image name in docker run
CMD ["python", "app.py"]     # exec form — python IS PID 1, signals arrive ✓
CMD python app.py            # shell form — an sh -c wrapper takes PID 1 ✗ (panel A!)

The JSON-array (exec) form starts your program directly; the bare (shell) form wraps it in sh -c — recreating panel A of the animation without you even writing an entrypoint. Kubernetes speaks the same dialect: command: and args: map onto ENTRYPOINT and CMD, and pod termination is the same SIGTERM-grace-SIGKILL dance from Lesson 12, with 30 seconds instead of 10.⁸ Finish by grading yourself:

bash check.sh
PASS  worker.sh is executable
PASS  entrypoint.sh is executable
PASS  entrypoint-noexec.sh is executable
PASS  entrypoint.sh hands over with exec "$@"
PASS  docker build -t p7lab .

5 passed, 0 failed

You can now — and phase 0 is complete

8.Check yourself

Who reads the #!/usr/bin/env bash line when you run ./greet.sh?

  1. the login shell
  2. the linux kernel
  3. the bash binary
  4. the docker daemon

What does the -e in set -euo pipefail do?

  1. echoes each command before running it
  2. makes every unset variable an error
  3. fails pipelines when any stage fails
  4. exits the script on first failure

A Dockerfile ends with shell-form CMD python app.py. When this container runs, PID 1 is…

  1. the sh -c wrapper
  2. the python process itself
  3. the docker daemon process
  4. the container entrypoint script

Why must the last line of an entrypoint be exec "$@" rather than just "$@"?

exec replaces the shell with the app — same PID, new program — so the app becomes PID 1 and receives docker stop's SIGTERM directly. Without exec the shell stays PID 1, has no trap and ignores SIGTERM, the app never hears it, and after the grace period everything is SIGKILLed: exit 137, no cleanup.

State the ENTRYPOINT/CMD contract in one sentence.

ENTRYPOINT is the program that always runs; CMD is its default arguments — and anything you type after the image name in docker run replaces CMD, arriving in the entrypoint as "$@".

A script using set -o pipefail and [[ ]] runs fine locally but fails inside an Alpine image. The most likely reason is:

  1. Alpine forbids pipelines inside shell scripts
  2. The script file is missing its execute bit
  3. Alpine's /bin/sh is busybox, not real bash
  4. Docker strips bash out of every base image

9.Go deeper

Primary source: the GNU Bash manual (the Set Builtin, Bourne Shell Builtins for exec, and Quoting) is the ground truth for everything here, and the Google Shell Style Guide is the short, opinionated "how professionals write it" companion. For the Docker half, the ENTRYPOINT/CMD interaction table is worth pinning.

The runway is built. Next up is the main course — Lesson 1: Your First Container — published here as the course progresses; watch the index. Later lessons like Lesson 8 (optimized images) and Lesson 12 (Kubernetes-ready) will read very differently now that you own signals, PID 1, and exec.

Stuck? Curious?

Bring questions to class, or open an issue on the course repo — include the command you ran and the output you got. The quizzes above are for self-checking: commit to an answer before revealing it, and re-try anything you missed tomorrow.

Sources

  1. man7 — execve(2) (kernel handling of #! interpreter scripts)
  2. GNU Bash manual — Shell Parameter Expansion (${1:?}, ${PORT:-8000})
  3. Google Shell Style Guide (quoting, error handling, when to use set -x)
  4. GNU Bash manual — The Set Builtin (-e, -u, -o pipefail semantics)
  5. Docker Docs — docker container stop (SIGTERM, grace period, SIGKILL)
  6. docker-library/postgres — docker-entrypoint.sh (set -Eeo pipefail; exec "$@", line 377 in 16-alpine)
  7. Docker Docs — Dockerfile reference: how CMD and ENTRYPOINT interact
  8. The Twelve-Factor App — Disposability (fast startup, graceful shutdown on SIGTERM)
  9. POSIX — Shell Command Language (The Open Group) — the standard /bin/sh implements; dash vs busybox vs bash portability verified in ubuntu:24.04 and alpine containers, 2026-07-19