Writing Lean Dockerfiles and Shell Entrypoints
Author multi-stage build scripts and robust entrypoint shims with signal handling and config templating.
Writing Lean Dockerfiles and Shell Entrypoints is a free DevOps Bootcamp lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Lean Dockerfiles Matter for DevOps
In production environments, every megabyte in a Docker image has a cost: slower pulls, larger attack surfaces, and wasted registry storage. Lean Dockerfiles combined with robust shell entrypoints are a hallmark of mature DevOps practice.
- Multi-stage builds separate build-time tooling from the final runtime image, dramatically reducing size.
- Entrypoint shims are small shell scripts that bootstrap containers: they template config files, validate environment variables, handle signals, and finally exec the main process.
- Together they form the backbone of reliable, portable container workloads in Kubernetes, ECS, and bare-metal environments.
This lesson covers both disciplines end-to-end, with production-grade patterns you can drop directly into your pipelines.
Anatomy of a Multi-Stage Dockerfile
A multi-stage Dockerfile uses multiple FROM instructions. Each stage is an isolated layer set; you copy only the artifacts you need into the next stage.
- Stage 0 (builder): installs compilers, test runners, build dependencies.
- Stage 1 (runtime): starts from a minimal base (e.g.
alpine,distroless) and copies only compiled binaries or app bundles. - The final image never contains
gcc,make, or source code unless you explicitly copy them.
Use --from=<stage> in COPY to pull files across stage boundaries. Name stages with AS <name> for readability and selective targeting with docker build --target.
# ---- Stage 0: builder ----
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /app/server ./cmd/server
# ---- Stage 1: runtime ----
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]Minimising Layers and Cache Busting
Every RUN, COPY, and ADD instruction creates a new layer. Poorly ordered instructions invalidate the build cache unnecessarily, making CI pipelines slow.
- Copy dependency manifests (
package.json,go.mod,requirements.txt) before copying source code so dependency installation is cached independently. - Chain related commands with
&&and clean up in the sameRUNlayer to avoid leaving package cache in intermediate layers. - Use
--no-cachein package managers and remove list files after install.
FROM python:3.12-slim AS builder
WORKDIR /app
# 1. Install deps first (cached until requirements change)
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# 2. Copy source (cache busted only when code changes)
COPY src/ ./src/
FROM python:3.12-slim
COPY --from=builder /install /usr/local
COPY --from=builder /app/src /app/src
WORKDIR /app
CMD ["python", "-m", "src.main"]BuildKit Secrets and SSH Forwarding
Private registries, SSH keys, and API tokens must never appear in image layers. Docker BuildKit provides two safe mechanisms:
--secret: mounts a secret file inside a singleRUNstep without baking it into a layer. Access via/run/secrets/<id>.--ssh: forwards the host SSH agent socket into the build sogit clonecan authenticate without embedding private keys.
Enable BuildKit with DOCKER_BUILDKIT=1 or via docker buildx build. The # syntax=docker/dockerfile:1 directive unlocks these features.
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
# Mount NPM token as a secret — never stored in the image
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --prefer-offline
# Usage at build time:
# DOCKER_BUILDKIT=1 docker build \
# --secret id=npm_token,src=~/.npmrc-token \
# -t myapp:latest .Writing a Robust Entrypoint Shim
The entrypoint shim is a shell script set as ENTRYPOINT in the Dockerfile. Its job is to prepare the runtime environment before handing control to the main process.
A well-structured shim follows this order:
- Step 1: Set
set -euo pipefailso any failure aborts early. - Step 2: Validate required environment variables and fail fast with a helpful message.
- Step 3: Template configuration files from environment variables.
- Step 4: Register signal handlers for graceful shutdown.
- Step 5:
exec "$@"— replace the shell with the main process so PID 1 is the application, not the shim.
The final exec is critical: without it, signals sent by Kubernetes or the Docker runtime are not forwarded to the child process.
#!/usr/bin/env bash
set -euo pipefail
# Step 2: validate required env vars
REQUIRED_VARS=(DATABASE_URL APP_SECRET PORT)
for var in "${REQUIRED_VARS[@]}"; do
if [[ -z "${!var:-}" ]]; then
echo "[entrypoint] ERROR: required env var '$var' is not set" >&2
exit 1
fi
done
# Step 3: template config (see next scene)
# Step 4: signal handling (see scene after that)
# Step 5: hand off to CMD
exec "$@"Config Templating with envsubst
envsubst (from the GNU gettext package, present in Alpine as gettext) substitutes ${VAR} placeholders in a template file with their current environment variable values.
- Ship a
*.tmplconfig template in the image; the entrypoint renders it at startup. - Pass the variable list explicitly to
envsubstso it does not accidentally expand unrelated dollar signs (e.g. in nginx regex). - Write the rendered file to a writable path like
/tmpor a dedicated config volume.
#!/usr/bin/env bash
# Template: /etc/nginx/conf.d/app.conf.tmpl contains:
# server { listen ${NGINX_PORT}; server_name ${SERVER_NAME}; ... }
export NGINX_PORT=${NGINX_PORT:-8080}
export SERVER_NAME=${SERVER_NAME:-localhost}
envsubst '${NGINX_PORT} ${SERVER_NAME}' \
< /etc/nginx/conf.d/app.conf.tmpl \
> /etc/nginx/conf.d/app.conf
echo "[entrypoint] nginx config rendered:"
grep -E 'listen|server_name' /etc/nginx/conf.d/app.conf
exec "$@"Signal Handling and Graceful Shutdown
Containers receive SIGTERM when stopped by Kubernetes, ECS, or docker stop. If your entrypoint shim is PID 1 and does not forward signals, the main process gets killed with SIGKILL after the grace period — causing dropped requests or data corruption.
- Use
trapto catchSIGTERMandSIGINTin the shim. - Forward the signal to the child PID using
kill -TERM "$child". - Use
wait "$child"to block until the child exits, then propagate its exit code. - Alternatively, use
execto replace the shell entirely — then the OS delivers signals directly to the child, no trap needed. This is the preferred pattern for simple cases.
#!/usr/bin/env bash
set -euo pipefail
# Start main process in background
"$@" &
child=$!
# Forward SIGTERM and SIGINT to the child
trap 'echo "[entrypoint] caught SIGTERM, forwarding..."; kill -TERM "$child"' TERM
trap 'echo "[entrypoint] caught SIGINT, forwarding..."; kill -INT "$child"' INT
# Wait for child to exit and capture its exit code
wait "$child"
exit $?Using tini as a Minimal Init Process
When your container spawns child processes (e.g. a shell that forks workers), you need a real init to reap zombie processes. tini is a tiny init binary purpose-built for containers.
- Add
tinito your image and set it as the entrypoint wrapper. - It reaps zombie children, forwards signals correctly, and exits with the child's status code.
- Docker ships a built-in tini activated with
docker run --init, but embedding it in the image ensures the behaviour is consistent across runtimes (Kubernetes, ECS, etc.).
FROM node:20-alpine
# Install tini for proper signal handling and zombie reaping
RUN apk add --no-cache tini
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
# tini wraps CMD; forwards SIGTERM and reaps zombies
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]Running as a Non-Root User
Containers that run as root (UID 0) are a major security risk: a container escape grants full host access. Always drop to an unprivileged user before executing the main process.
- Create a dedicated system user and group in the Dockerfile with
addgroup/adduser(Alpine) orgroupadd/useradd(Debian). - Change ownership of app files with
COPY --chown=appuser:appgroup— more efficient than a separateRUN chownlayer. - Switch to the user with the
USERinstruction. The entrypoint and CMD inherit this user. - Kubernetes
securityContext.runAsNonRoot: truewill refuse to start an image that still runs as root.
FROM python:3.12-slim
# Create non-root user
RUN groupadd --gid 1001 appgroup && \
useradd --uid 1001 --gid appgroup --shell /bin/bash --create-home appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy app files with correct ownership in a single layer
COPY --chown=appuser:appgroup src/ ./src/
COPY --chown=appuser:appgroup entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER appuser
ENTRYPOINT ["/entrypoint.sh"]
CMD ["python", "-m", "src.main"]Health Checks and Readiness Probes in the Image
Kubernetes liveness and readiness probes are defined in manifests, but you can also bake a HEALTHCHECK into the Dockerfile for standalone docker run and Docker Compose environments.
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 CMD ...- Use
curl --failorwget -qO-for HTTP services; for non-HTTP daemons, probe a socket with/dev/tcp/localhost/PORT. - Install only what you need: in distroless images, avoid adding
curljust for health checks — use a purpose-built probe binary or the application's own health binary.
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/nginx.conf
COPY dist/ /usr/share/nginx/html/
# Lightweight health check using bash TCP pseudo-device
# (no curl needed — works on any image with bash)
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD bash -c 'exec 3<>/dev/tcp/localhost/80 && echo -e "GET /health HTTP/1.0\r\n" >&3 && cat <&3 | grep -q "200 OK"' || exit 1
EXPOSE 80Putting It All Together: A Production Entrypoint
The following is a complete, production-grade entrypoint shim combining all the patterns from this lesson: env validation, config templating, signal forwarding, and exec hand-off. This pattern is used in real-world Node.js, Python, and Go microservices deployed on Kubernetes.
- Every section is commented to serve as a self-documenting template.
- The shim is kept under 50 lines — entrypoints should be simple and auditable.
- Notice the final
exec "$@": after all setup is done, the shell is replaced by the application process so it becomes PID 1 and receives all OS signals directly.
#!/usr/bin/env bash
# entrypoint.sh — production-grade container entrypoint shim
set -euo pipefail
# ── 1. Validate required environment variables ──────────────────
REQUIRED=(DATABASE_URL APP_SECRET PORT LOG_LEVEL)
for var in "${REQUIRED[@]}"; do
[[ -n "${!var:-}" ]] || { echo "[entrypoint] FATAL: $var is not set" >&2; exit 1; }
done
# ── 2. Set safe defaults for optional variables ─────────────────
export HOST=${HOST:-0.0.0.0}
export WORKERS=${WORKERS:-2}
# ── 3. Render config template ───────────────────────────────────
if [[ -f /etc/app/app.conf.tmpl ]]; then
envsubst '${DATABASE_URL} ${PORT} ${LOG_LEVEL} ${HOST}' \
< /etc/app/app.conf.tmpl \
> /etc/app/app.conf
echo "[entrypoint] config rendered at /etc/app/app.conf"
fi
# ── 4. Wait for dependent services (optional, fast) ─────────────
if [[ -n "${WAIT_FOR_HOST:-}" ]]; then
echo "[entrypoint] waiting for ${WAIT_FOR_HOST}:${WAIT_FOR_PORT:-5432}..."
until bash -c "exec 3<>/dev/tcp/${WAIT_FOR_HOST}/${WAIT_FOR_PORT:-5432}" 2>/dev/null; do
sleep 1
done
echo "[entrypoint] dependency ready"
fi
# ── 5. Hand off to CMD (PID 1 becomes the application) ──────────
echo "[entrypoint] starting: $*"
exec "$@"Knowledge Check: Signal Handling in Entrypoints
Test your understanding of signal handling in container entrypoint scripts.
Recap: Lean Dockerfiles and Shell Entrypoints
You have covered the full stack of production container authoring:
- Multi-stage builds use multiple
FROMinstructions to keep compilers and build tools out of the final image, producing lean, minimal runtime layers. - Layer ordering — copy dependency manifests before source code — maximises cache hits and speeds up CI pipelines.
- BuildKit secrets and SSH mounts keep credentials out of image history without breaking authenticated builds.
- Entrypoint shims validate environment variables, template config files with
envsubst, and hand off to the application viaexec "$@". - Signal handling requires either
exec(so the app is PID 1) or an explicittrap+kill+waitpattern when background jobs are used. - tini adds zombie reaping and correct signal forwarding when the container spawns multiple processes.
- Non-root users and
HEALTHCHECKinstructions complete a secure, observable image ready for Kubernetes production workloads.
Combine these patterns consistently and your images will be smaller, faster to deploy, and significantly more robust under production conditions.
Frequently asked questions
Is the “Writing Lean Dockerfiles and Shell Entrypoints” lesson free?
Yes — the full text of “Writing Lean Dockerfiles and Shell Entrypoints” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Writing Lean Dockerfiles and Shell Entrypoints”?
Author multi-stage build scripts and robust entrypoint shims with signal handling and config templating. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start DevOps Bootcamp?
No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Writing Lean Dockerfiles and Shell Entrypoints” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this DevOps Bootcamp lesson?
Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Writing Lean Dockerfiles and Shell Entrypoints
- Templating Configs with envsubst and heredocs
- Scripting Cloud Resources via CLI and jq
- Health Probes, Readiness Gates, and Wait Loops