Bash as a Docker Entrypoint: PID 1, Signals and exec

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
podman 5.8.2-5.el10_2
Applies to Linux hosts with Docker Engine or Podman
Privilege Normal user with permission to build and run containers
Scope Bash entrypoint scripts for containers: exec vs shell form ENTRYPOINT/CMD, exec "$@", PID 1 signal and zombie behavior, podman stop / docker stop testing, shebang and CRLF failures, optional --init, and when a wrapper is unnecessary. Does not replace a full container security or orchestration guide.
Related guides Shell scripting course hub
Short on time?Beta Get the gist of this 11-minute read in seconds, then ask follow-up questions

A Bash entrypoint should usually perform its startup work and finish with exec "$@". exec replaces Bash with the container's main application so that application becomes PID 1 and receives stop signals directly.

bash
#!/usr/bin/env bash
set -euo pipefail

# initialization here
mkdir -p /tmp/run

exec "$@"

Pair the script with exec-form ENTRYPOINT and CMD:

dockerfile
ENTRYPOINT ["/entrypoint.sh"]
CMD ["myapp", "--flag"]

ENTRYPOINT starts the wrapper. CMD supplies its default arguments. exec "$@" replaces Bash with the final application so that application becomes PID 1 and receives container stop signals directly. The examples use podman on RHEL 10.2; replace podman with docker if you use Docker Engine — the CLI flags and signal semantics match for these tests.


What ENTRYPOINT and CMD Do

ENTRYPOINT in a Dockerfile defines the main executable for the image. CMD supplies default arguments. With exec-form ENTRYPOINT and CMD, the runtime combines them into the command and arguments used to start the container.

Piece Role
ENTRYPOINT Fixed executable or wrapper — often a script that prepares the environment
CMD Default arguments appended to ENTRYPOINT unless the operator overrides them at run time
Process started at boot Becomes PID 1 inside the container unless an init helper sits in front

For ENTRYPOINT ["/entrypoint.sh"] and CMD ["sleep", "infinity"], the runtime invokes:

text
/entrypoint.sh sleep infinity

Inside the script, $1 is sleep and $2 is infinity; exec "$@" becomes exec sleep infinity.

Operators can replace an image's ENTRYPOINT with docker run --entrypoint ... or podman run --entrypoint ... — useful when debugging a wrapper script.


Shell Form vs Exec Form

Dockerfile instructions have two syntaxes:

Form Example What the runtime actually runs
Shell form ENTRYPOINT /app.sh /bin/sh -c '/app.sh' — a shell sits between the runtime and your script
Exec form (JSON) ENTRYPOINT ["/entrypoint.sh"] /entrypoint.sh directly — no extra /bin/sh -c wrapper from the Dockerfile line

Prefer exec form for ENTRYPOINT and CMD. Shell form introduces /bin/sh -c, which can leave the intended application as a child process and interfere with signal handling. Shell form is useful when you intentionally need shell expansion, pipelines, or command chaining, but it should not be the default for a long-running container process.

When CMD supplies arguments to an exec-form ENTRYPOINT, use JSON/exec form for CMD as well so arguments reach the entrypoint predictably:

dockerfile
ENTRYPOINT ["/entrypoint.sh"]
CMD ["app", "--flag"]

Exec form does not perform shell expansion, globbing, pipelines, or command substitution. ENTRYPOINT ["echo", "$HOME"] does not expand $HOME. If you need those features, run a shell explicitly or put the logic in the entrypoint script.


Why PID 1 Matters

By default, docker stop and podman stop send SIGTERM to the container's main process, then wait for the configured stop timeout before forcing termination if it remains running. Images can override the stop signal through STOPSIGNAL or runtime configuration, but the default pattern is what most entrypoint scripts must handle.

Container stop signals are delivered to PID 1. If Bash remains PID 1 and starts the application as a child, the application does not automatically receive the signal. The wrapper must either replace itself with exec or deliberately trap and forward signals to the child.

PID 1 inside a container is not a full init system like systemd, but the kernel still treats it specially. PID 1 also has an important reaping role. Orphaned descendants may eventually be reparented to PID 1, which must reap exited children to prevent zombies. A process must still wait for children it directly owns.

There are three common process layouts:

Layout When it fits Signal note
No wrapper Single static binary, no setup App is PID 1 — handles stop signals directly
Init wrapper + exec "$@" One main process after setup Preferred default for Bash entrypoints
Bash supervises multiple children Multiple cooperating processes intentionally running in one container Bash stays PID 1 — forward TERM, wait for exits, and reap zombies

Why exec "$@" Matters

exec replaces the current shell process with the command you name. It does not start a second long-lived Bash parent.

Without exec, the tree looks like this:

text
PID 1  bash /entrypoint.sh
PID 7  myapp

With exec "$@" after setup:

text
PID 1  myapp

With an exec-form ENTRYPOINT, "$@" receives the image's exec-form CMD arguments unless the user replaces those arguments on docker run or podman run. Do not assume the same behavior with shell-form ENTRYPOINT.

exec does not add another process layer. After a successful exec, the shell image is gone from the process table and the target keeps PID 1.

exec "$@" assumes that at least one command was supplied, normally through CMD or runtime arguments. If "$@" is empty, exec has no command to replace the shell with, so the entrypoint can continue or exit without starting the intended application. "$@" with zero positional parameters safely expands to zero arguments, even under set -u; the failure mode is a bare exec with no command, not set -u itself. Validate $# when your image may be run without a command:

bash
if (( $# == 0 )); then
  echo "entrypoint: no command supplied" >&2
  exit 64
fi
exec "$@"

Pass CMD Arguments Through the Entrypoint

Override only CMD at run time:

bash
podman run --rm entrypoint-lab:exec echo hello
output
init: preparing dirs
hello

The entrypoint still runs initialization; echo hello replaces the default sleep infinity arguments.

Skip initialization for admin subcommands by branching early:

bash
#!/bin/bash
set -euo pipefail
case "${1:-}" in
  shell)
    exec /bin/bash
    ;;
  migrate)
    shift
    exec /app/migrate "$@"
    ;;
esac
mkdir -p /data
exec "$@"

shift removes the migrate selector before forwarding remaining arguments. Without it, podman run img migrate foo would run /app/migrate migrate foo.

Use a wrapper when the image needs work before the main binary starts:

  • create volumes or fix permissions on mounted paths
  • render a config file from environment variables
  • wait for a dependency socket

Keep startup initialization short. Long-running migrations are usually better handled as a separate deployment or orchestration step rather than repeated on every container start. After setup, always exec the main process unless you deliberately intend to supervise children from Bash.

Some entrypoints perform root-only initialization and then drop privileges before exec; the exact mechanism depends on the image and is outside this entrypoint/signals guide.

Build and verify the handoff:

bash
podman build -t entrypoint-lab:exec /tmp/docker-entrypoint-lab/entrypoint-init-exec-form
output
Successfully tagged localhost/entrypoint-lab:exec

Run the container and read PID 1's command line inside the namespace:

bash
cid=$(podman run -d --name lab-exec entrypoint-lab:exec)
podman exec "$cid" sh -c "tr '\0' ' ' < /proc/1/cmdline; echo"
output
/usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep infinity

Initialization ran first, then exec replaced Bash with sleep, which now runs as PID 1.


Test SIGTERM and Graceful Shutdown

Use an app script that logs signals and exits on SIGTERM:

bash
#!/bin/bash
trap 'echo "Caught SIGTERM, exiting 0"; exit 0' TERM
echo "App started, PID=$$, PPID=$PPID"
while true; do sleep 1; done

Build with ENTRYPOINT ["/app.sh"] (exec form, app as PID 1). Stop with a two-second grace period:

bash
podman run -d --name lab-stop entrypoint-lab:app
time podman stop -t 2 lab-stop
podman logs lab-stop
output
real    0m3.41s
App started, PID=1, PPID=0
Caught SIGTERM, exiting 0

The container exits 0 because PID 1 handled the stop signal. While Bash is waiting for the foreground sleep, execution of the TERM trap is deferred until sleep returns. Because this example sleeps for only one second at a time, the trap runs shortly after the signal arrives. A TERM trap must ultimately let the process exit; otherwise the runtime may send SIGKILL when the stop timeout expires.

Compare a wrapper that starts the app without exec:

bash
#!/bin/bash
/app.sh

After podman stop, logs show startup only — no Caught SIGTERM line — because PID 1 was the wrapper Bash, not the app.

Forward signals when Bash must stay in the middle — see Bash trap for trap handlers that kill children on TERM.


When Bash Must Stay PID 1

If Bash remains PID 1, it must explicitly forward termination signals to the child and wait for that child to exit. Signal forwarding and waiting solve different parts of supervision — wait by itself does not forward SIGTERM.

Homemade Bash supervision works for demos. Production containers usually keep one main application or service per container. If that application creates child processes, make sure they are reaped correctly or place a small init process in front (tini, dumb-init, s6). Orchestrator restarts are often preferable to growing in-container process trees.


Zombie Processes and --init

podman run --init (or docker run --init) inserts a small init process as PID 1:

text
PID 1  podman-init
PID 4  bash /entrypoint.sh
PID 5  app workers…

--init can forward signals and reap orphaned descendants that are reparented to it. It does not replace wait in a parent process that still owns its children. Enable it when you cannot exec a single child and orphaned processes may reparent to PID 1.


Entrypoint Troubleshooting

The entrypoint file must be executable inside the image (chmod +x in the Dockerfile or COPY --chmod=755).

The shebang must be the first bytes of the file. A UTF-8 BOM or blank line before #!/bin/bash breaks direct execution — see Bash shebang.

If an entrypoint exists but returns no such file or directory, verify its shebang and line endings. CRLF can turn /bin/bash into /bin/bash\r:

bash
file entrypoint.sh
output
Bourne-Again shell script, ASCII text executable, with CRLF line terminators

Convert before build:

bash
sed -i 's/\r$//' entrypoint.sh

Or use dos2unix entrypoint.sh and enforce LF in .gitattributes.


Common Entrypoint Mistakes

Symptom Likely cause Fix
App ignores docker stop Bash wrapper is PID 1; app is a child exec "$@" after init
required file not found on exec form CRLF shebang or missing chmod +x sed -i 's/\r$//'; RUN chmod +x
Init runs on every run subcommand Unconditional setup before case Branch on $1 for admin commands
Zombie processes accumulate Children not reaped exec single child, wait in supervisor, or --init
Shell form hides signals /bin/sh -c wrapper Prefer JSON exec form
Stop always waits full timeout then kills Trap logs but never exits exit from TERM handler
Main application never starts No CMD or runtime command supplied Validate $# before exec "$@"

Complete Tested Example

Lab layout under /tmp/docker-entrypoint-lab:

text
entrypoint.sh
app.sh
Dockerfile

entrypoint.sh:

bash
#!/bin/bash
set -euo pipefail
echo "init: preparing dirs"
mkdir -p /tmp/run
exec "$@"

app.sh:

bash
#!/bin/bash
trap 'echo "Caught SIGTERM at $(date +%T), exiting 0"; exit 0' TERM
echo "App started, PID=$$, PPID=$PPID"
while true; do sleep 1; done

Dockerfile:

dockerfile
FROM quay.io/rockylinux/rockylinux:10
COPY entrypoint.sh /entrypoint.sh
COPY app.sh /app.sh
RUN chmod +x /entrypoint.sh /app.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/app.sh"]

Build and run:

bash
podman build -t entrypoint-lab:full .
podman run -d --name lab-full entrypoint-lab:full
podman stop -t 2 lab-full && podman logs lab-full
output
App started, PID=1, PPID=0
Caught SIGTERM at 16:55:08, exiting 0

For run containers with podman run flags such as publish ports and detached mode, use the same patterns with your service binary in CMD.


When You Do Not Need a Bash Entrypoint

Skip Bash when:

  • the application binary needs no setup — use ENTRYPOINT ["app"] or CMD alone
  • a compiled binary reads configuration from the environment without templating
  • startup work belongs in a separate deployment step

Keep a Bash entrypoint only when startup requires shell logic such as argument handling, configuration generation, or permission changes — then exec the real server.


Summary

A Bash Docker entrypoint should usually do a small amount of setup and then exec "$@" so the application becomes PID 1 and receives stop signals from docker stop or podman stop. Use exec-form ENTRYPOINT and CMD so argv flows predictably and shell wrappers do not intercept signals.

If Bash must stay PID 1 to manage multiple children, forward signals, wait for exits, and consider docker run --init or a dedicated init binary. Test shutdown on your current engine version — a TERM trap must ultimately let the process exit; otherwise the runtime may send SIGKILL when the stop timeout expires.

When a single binary needs no setup, drop the wrapper and let the application run as PID 1 directly.


References


Frequently Asked Questions

Why does every entrypoint use exec "$@"?

It runs initialization in Bash, then replaces the shell with the real command so the application becomes PID 1 and receives stop signals directly. Without exec, Bash stays PID 1 and the app is a child that may never see SIGTERM.

Does exec start another process?

No. exec replaces the current process image. The PID stays the same; only the program code changes. After exec "$@", Bash no longer appears in the process list.

Why doesn't my app receive SIGTERM?

PID 1 is probably still the entrypoint shell, or a /bin/sh -c wrapper from shell-form ENTRYPOINT. Use exec form, end the wrapper with exec "$@", and confirm with /proc/1/cmdline inside the container.

Should Bash remain PID 1?

Only when you deliberately supervise multiple processes and handle forwarding and reaping. For a single main server, exec into that server so it holds PID 1.

ENTRYPOINT vs CMD?

ENTRYPOINT sets the executable that always runs. CMD supplies default arguments you can override at podman run or docker run time. Together they form the full argv vector passed to your wrapper or binary.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)