5. Building Images with Dockerfiles
A Dockerfile is the source code of an image: a plain-text recipe that the builder executes step by step to produce the layers and configuration described in chapter 4. This chapter covers the instructions that shape the filesystem and the runtime behavior of an image, how docker build turns a directory into an image and how its cache decides what to rebuild, how multi-stage builds separate build tools from the shipped artifact, and the practices that keep images small, fast to build, and reproducible. Almost every container you run in development, CI, and production starts from a Dockerfile, so writing them well pays off everywhere else. BuildKit-specific features such as cache mounts, secrets, and multi-platform builds are covered later in chapter 9.
<a id="51-dockerfile-basics"></a>
5.1 Dockerfile Basics
This section introduces the Dockerfile format and the core instructions that select a base image and build up the image's filesystem.
Dockerfile Syntax
Why a Dockerfile. You could build an image by starting a container, installing things by hand, and running docker commit (see Committing Containers to Images). The result is a black box: nobody knows exactly what was done, it cannot be reviewed or reproduced, and rebuilding it with a security patch means repeating the manual work. A Dockerfile turns the procedure into versioned, reviewable code that any machine can execute to obtain the same image.
Analogy. A Dockerfile is to an image what a recipe is to a cake. The recipe can be shared, reviewed, and improved; the cake is the output you serve. If you only kept cakes and never recipes, you could never bake the same one again with less sugar.
The format. A Dockerfile is a text file (named Dockerfile by default) containing one instruction per line:
INSTRUCTION arguments
| Element | Rule |
|---|---|
| Instructions | Case-insensitive, but written in UPPERCASE by convention to distinguish them from arguments |
| Order | Executed top to bottom; the first instruction must be FROM (only parser directives, comments, and global ARGs may precede it) |
| Comments | A line starting with # is a comment; a # in the middle of a line is not a comment, it is passed as part of the argument |
| Line continuation | A trailing \ joins the next line to the current one (the escape character can be changed with a parser directive) |
| Parser directives | Special comments # directive=value that must appear at the very top of the file, before any other line, comment, or blank line |
| Variable substitution | $VAR and ${VAR} are expanded in FROM, COPY, ADD, ENV, ARG, WORKDIR, USER, EXPOSE, LABEL, VOLUME, STOPSIGNAL; in RUN, CMD, ENTRYPOINT expansion is done by the shell, if there is one |
Parser directives you will meet:
# syntax=docker/dockerfile:1selects the Dockerfile frontend (the parser BuildKit uses), so you get the latest stable syntax features regardless of the Docker version installed. Covered in Dockerfile Frontends and Syntax Directive.# escape=`changes the escape character to a backtick, useful on Windows where\is the path separator.# check=skip=<rules>or# check=error=trueconfigures the build checks (linting) that BuildKit runs on the Dockerfile.
Variable expansion modifiers. Besides $VAR and ${VAR}, two modifiers are supported for instructions the builder expands itself:
| Syntax | Meaning |
|---|---|
${VAR:-default} | Use default if VAR is unset or empty |
${VAR:+alt} | Use alt if VAR is set and non-empty, otherwise empty |
Instruction overview. Instructions fall into two groups, which mirror the two halves of an image (layers and config):
Filesystem instructions (create layers) Metadata instructions (change config only)
--------------------------------------- ------------------------------------------
RUN execute a command CMD default command
COPY copy files from the context ENTRYPOINT fixed executable
ADD copy files, URLs, archives ENV environment variables
WORKDIR working directory (*)
Build-control instructions USER default user
--------------------------------------- EXPOSE documented ports
FROM start a stage from a base image VOLUME mount points
ARG build-time variable LABEL metadata key/value pairs
SHELL default shell for shell form HEALTHCHECK health probe
ONBUILD trigger for child images STOPSIGNAL stop signal
(*) WORKDIR also creates the directory if it does not exist
Example: an annotated Dockerfile
# syntax=docker/dockerfile:1
# ^ parser directive: must be the very first line
# A normal comment. Blank lines are ignored.
FROM python:3.13-slim
# Metadata only, no layer
LABEL org.opencontainers.image.source="https://github.com/acme/hello"
WORKDIR /app
# Line continuation keeps long commands readable
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY app.py .
# The '#' below is NOT a Dockerfile comment: the whole line is handed to /bin/sh,
# which treats "# world" as a shell comment, so this prints "hello". In instructions
# without a shell (COPY, WORKDIR, CMD in exec form, ...) it would become an argument.
RUN echo hello # world
ENV PORT=8000
CMD ["python", "app.py"]
# Build the Dockerfile in the current directory and tag the result
docker build -t hello:dev .
# Lint the Dockerfile without building it (BuildKit build checks)
docker build --check .
Key takeaways
- A Dockerfile is the reproducible, reviewable source of an image;
docker commitis not. - One instruction per line, executed in order, starting with
FROM. #starts a comment only at the beginning of a line; parser directives must be the very first lines.RUN,COPY, andADDcreate layers; most other instructions only modify the image config.- Use
# syntax=docker/dockerfile:1to get current Dockerfile features independently of the installed Docker version.
🧪 Practice
- (Beginner) Write a three-line Dockerfile based on
alpine:3.20that prints a message at startup. Build it, run it, and usedocker image historyto see which lines produced layers.- (Intermediate) Add
# syntax=docker/dockerfile:1after a comment line and build again. Then move it to the first line. Explain what changed and why the position matters.- Interview question: "Why do teams prefer Dockerfiles over
docker commit, even for a one-off image?" Hint: think about review, auditability, rebuilding with patched base images, and caching.
FROM
Why FROM. Nobody wants to assemble a Linux userland, a language runtime, and its libraries from nothing for every application. FROM lets you stand on a maintained foundation (see Base Images) and add only what is specific to your application. It also starts a new build stage, which is the building block of multi-stage builds.
Syntax
FROM [--platform=<platform>] <image>[:<tag>|@<digest>] [AS <name>]
| Part | Purpose |
|---|---|
<image> | Any image reference: ubuntu, ghcr.io/acme/base, a local image, or the special name scratch |
:<tag> | Defaults to latest if omitted, which is almost never what you want |
@<digest> | Pins the exact content; the tag, if present, becomes documentation only |
AS <name> | Names the stage so later stages can reference it (COPY --from=<name>, FROM <name>) |
--platform | Selects the platform variant of a multi-platform image, e.g. linux/amd64; mostly used with $BUILDPLATFORM for cross-compilation |
What FROM does. The builder resolves the reference, pulls the image if it is not available locally, and uses its layers and its config (environment, CMD, ENTRYPOINT, WORKDIR, USER, labels) as the starting point. Everything the base image defines is inherited until you override it.
FROM python:3.13-slim
|
v
+-------------------------------------+
| inherited layers (debian + python) |
| inherited config: |
| ENV PATH=/usr/local/bin:... |
| ENV LANG=C.UTF-8 |
| CMD ["python3"] |
+-------------------------------------+
|
v your instructions add layers on top and override config
Special cases
FROM scratchstarts from an empty filesystem with no shell, no libc, nothing. It is used for static binaries and is covered in Distroless and Scratch Images.- A Dockerfile can contain several
FROMlines. Each one starts a fresh stage; only the last stage (or the one selected with--target) becomes the output image. - An
ARGdeclared before the firstFROMcan be used inFROMlines, which lets you parameterize the base image.
Choosing the reference. Prefer a specific tag (python:3.13-slim), and for production pin a digest as well (see Pinning Versions). Avoid latest: the build will silently change when upstream publishes a new major version.
Example
# syntax=docker/dockerfile:1
# Global ARG: usable in FROM lines only (unless re-declared inside a stage)
ARG PYTHON_VERSION=3.13
# Tag for humans, digest for reproducibility
FROM python:${PYTHON_VERSION}-slim@sha256:<digest> AS base
# A second stage that starts from the first one
FROM base AS app
COPY app.py /app/app.py
CMD ["python", "/app/app.py"]
# Inspect what a base image will make you inherit
docker image inspect python:3.13-slim \
--format 'Env: {{.Config.Env}}{{"\n"}}Cmd: {{.Config.Cmd}}{{"\n"}}User: "{{.Config.User}}"'
# Build against a different base version without editing the file
docker build --build-arg PYTHON_VERSION=3.12 -t app:py312 .
Key takeaways
FROMselects the base image and starts a new build stage; it must come first.- Layers and configuration (
ENV,CMD,USER, ...) are inherited from the base image. - Always use an explicit tag; pin a digest when you need reproducible builds.
AS <name>names a stage for multi-stage builds;FROM scratchstarts empty.ARGbeforeFROMparameterizes the base image.
🧪 Practice
- (Beginner) Build an image
FROM python:3.13-slimwith no other instructions and run it. Explain why you land in a Python REPL even though your Dockerfile has noCMD.- (Intermediate) Parameterize the base image with a global
ARG, then try toRUN echo $PYTHON_VERSIONinside the stage. Explain the empty output and fix it.- Interview question: "A build that has not changed in months suddenly fails in CI with a dependency error. The Dockerfile starts with
FROM node. What happened, and how do you prevent it?" Hint: think about whatlatestpoints to over time and what tags and digests guarantee.
RUN
Why RUN. A base image rarely has everything your application needs: system packages, a compiled dependency, a configured directory structure. RUN executes a command inside a temporary container during the build and saves the resulting filesystem changes as a new layer. It is how you "install things" into an image.
How it works.
previous layer stack
|
v
+--------------------+ 1. start a temporary container from the current state
| temp container | 2. execute the command (as the current USER, in WORKDIR)
| apt-get install | 3. if the exit code is not 0, the build fails
+--------------------+ 4. snapshot the filesystem diff as a new layer
|
v
new layer on top; the temp container is discarded
Only the filesystem survives. Processes started by RUN are killed when the step ends, and shell state (variables set with export, cd) does not carry over to the next RUN, because each one runs in a fresh shell.
Two forms.
# Shell form: run through /bin/sh -c, so pipes, &&, and $VARS work
RUN apt-get update && apt-get install -y curl
# Exec form: run the binary directly, no shell processing
RUN ["/usr/local/bin/setup", "--mode", "prod"]
The differences are explored in Shell vs Exec Form. For RUN, the shell form is used in the vast majority of cases.
Writing robust RUN steps
| Problem | Cause | Fix |
|---|---|---|
apt-get install uses a stale package index | apt-get update was cached in an earlier, separate RUN | Always update and install in the same RUN |
| Image is large after "cleaning up" | Cleanup in a later RUN only hides files from an earlier layer | Clean up in the same RUN that created the files |
curl ... | tar x "succeeds" though the download failed | /bin/sh returns the exit code of the last command in a pipe | SHELL ["/bin/bash", "-o", "pipefail", "-c"], or set -o pipefail where supported |
| A command in a long chain fails silently | ; continues after errors | Chain with &&, or start with set -eux |
| Build hangs waiting for input | Interactive prompts (tzdata, debconf) | DEBIAN_FRONTEND=noninteractive on that RUN, -y flags |
Example
# syntax=docker/dockerfile:1
FROM debian:12-slim
# One logical step, one layer: update, install, clean up together
RUN set -eux; \
apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
curl; \
rm -rf /var/lib/apt/lists/* # remove the package index in the same layer
# Pipes need pipefail to catch failures on the left side
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -fsSL https://example.com/tool.tar.gz | tar -xz -C /usr/local/bin
# Each RUN is a new shell: this 'cd' has no effect on the next line
RUN cd /tmp
RUN pwd # prints "/", not "/tmp" (use WORKDIR instead)
Key takeaways
RUNexecutes a command in a temporary container and commits the filesystem changes as a layer.- Each
RUNstarts a fresh shell; only files persist, not processes, variables, or the current directory. - Combine
apt-get updatewithinstall, and clean up in the sameRUN. - Use
&&orset -e, andpipefailfor pipes, so failures stop the build. - A non-zero exit code fails the build, which is what you want.
🧪 Practice
- (Beginner) Write a Dockerfile that installs
curlondebian:12-slimin two separateRUNsteps (update, then install) and another in one step with cleanup. Compare the image sizes.- (Intermediate) Write
RUN false | trueand build it, then add theSHELLinstruction withpipefailand build again. Explain both results.- Interview question: "Why can a Dockerfile that runs
RUN apt-get updateandRUN apt-get install -y nginxon separate lines install an outdated or missing package version months later?" Hint: think about what the build cache uses as the key for aRUNstep.
COPY vs ADD
Why two instructions. Your application code lives on your machine, not in the base image, so you need a way to bring files into the image. COPY does exactly that and nothing more. ADD came first and does more: it can also fetch remote URLs and Git repositories and automatically unpack local archives. That extra behavior is sometimes useful but often surprising, so modern guidance is: use COPY unless you specifically need a feature of ADD.
Analogy. COPY is moving boxes into a new house exactly as packed. ADD is a mover who also unpacks any box that looks like an archive and fetches extra boxes from addresses you give them, which is helpful only when you asked for it.
Comparison
| Capability | COPY | ADD |
|---|---|---|
| Copy files/directories from the build context | Yes | Yes |
Copy from another stage or image (--from) | Yes | No |
| Download from an HTTP(S) URL | No | Yes (file is not extracted) |
Clone a Git repository (ADD https://github.com/org/repo.git#ref) | No | Yes |
Auto-extract local tar archives (gzip, bzip2, xz) | No | Yes |
Verify a download checksum (--checksum=sha256:...) | n/a | Yes |
--chown, --chmod, --link | Yes | Yes |
| Predictability | High | Depends on the source type |
Path rules that apply to both
- Sources are relative to the build context, and you cannot reach outside it (
COPY ../secret .fails). - Copying a directory copies its contents, not the directory itself:
COPY src/ /app/puts the files ofsrcdirectly into/app. - When copying several sources, the destination must be a directory and end with
/. - A relative destination is resolved against
WORKDIR. - Missing destination directories are created automatically.
- Wildcards follow Go's
filepath.Matchrules:COPY *.json ./. - Files are owned by
root:rootunless you pass--chown, regardless of any earlierUSERinstruction.
Build context COPY src/ /app/ COPY src /app/src/
. /app /app
+-- Dockerfile +-- main.py +-- src
+-- src +-- util.py +-- main.py
+-- main.py +-- util.py
+-- util.py
Example
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
# Plain, predictable copy with ownership set at copy time (no extra chown layer)
COPY --chown=10001:10001 requirements.txt ./
COPY --chown=10001:10001 src/ ./src/
# Legitimate ADD use 1: download a release with integrity verification
ADD --checksum=sha256:<expected-sha256> \
https://example.com/releases/tool-1.4.0-linux-amd64 /usr/local/bin/tool
RUN chmod +x /usr/local/bin/tool
# Legitimate ADD use 2: unpack a vendored tarball from the context
# extracted automatically
ADD vendor/rootfs-extras.tar.gz /opt/extras/
# Legitimate ADD use 3: pull a pinned Git ref without installing git
ADD https://github.com/acme/schemas.git#v2.1.0 /opt/schemas
Key takeaways
- Prefer
COPY: it only copies local files (or files from other stages) and has no surprises. - Use
ADDdeliberately for remote URLs with--checksum, Git sources, or auto-extracting local tarballs. - Remote files fetched by
ADDare not extracted; local archives are. - Copying a directory copies its contents; set ownership with
--chownrather than a laterRUN chown. - Sources must be inside the build context.
🧪 Practice
- (Beginner) Create
data.tar.gzcontaining two text files. UseCOPYin one image andADDin another to place it in/data/, then list/datain each and explain the difference.- (Intermediate) Compare
COPY app/ /srv/with aCOPYfollowed byRUN chown -R 10001:10001 /srvversusCOPY --chown=10001:10001. Usedocker image historyto compare layer sizes and explain why one approach doubles the space.- Interview question: "When would you still choose
ADDoverCOPYin a modern Dockerfile?" Hint: consider checksummed remote downloads, Git sources at a specific ref, and archives you want extracted.
WORKDIR
Why WORKDIR. Commands and relative paths need a current directory. Without WORKDIR, everything happens in /, and Dockerfiles fill up with absolute paths or with RUN cd /app && ... chains, because cd in one RUN does not survive to the next. WORKDIR sets the directory once for all following RUN, CMD, ENTRYPOINT, COPY, and ADD instructions, and it is also the default directory of containers started from the image.
Behavior
- Creates the directory (and any parents) if it does not exist.
- Can be used multiple times; a relative path is resolved against the previous
WORKDIR. - Expands environment variables set with
ENVorARG. - Is inherited from the base image; many images set their own (check with
docker image inspect). - Can be overridden at runtime with
docker run -w(see Working Directory and User).
# current: /srv
WORKDIR /srv
# relative -> /srv/app
WORKDIR app
# -> /srv/logs
WORKDIR ../logs
Example
# syntax=docker/dockerfile:1
FROM node:22-slim
ENV APP_HOME=/usr/src/app
# variable expansion; directory is created
WORKDIR $APP_HOME
# "./" means $APP_HOME
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# Anti-pattern for comparison (do not do this):
# RUN mkdir -p /usr/src/app && cd /usr/src/app && npm ci
# COPY . /usr/src/app
# runs in /usr/src/app
CMD ["node", "server.js"]
docker build -t web .
docker run --rm web pwd # /usr/src/app
docker run --rm -w /tmp web pwd # /tmp (runtime override)
Key takeaways
WORKDIRsets the current directory for later build steps and for the running container.- It creates the directory automatically; no
mkdirneeded. - Use absolute paths for clarity; relative
WORKDIRs stack on the previous one. - Never rely on
RUN cd: it only lasts for that singleRUN.
🧪 Practice
- (Beginner) Write a Dockerfile with
WORKDIR /a,WORKDIR b,WORKDIR c, andCMD ["pwd"]. Predict the output, then verify it.- (Intermediate) Build an image
basethat setsWORKDIR /srv/app, then a second imageFROM basethat only doesCOPY notes.txt .and has noWORKDIRof its own. Predict wherenotes.txtlands, verify it, and explain why.- Interview question: "Why is
RUN cd /app && makeacceptable whileRUN cd /appfollowed byRUN makeis a bug?" Hint: think about what state eachRUNstep starts with.
ENV and ARG
Why two kinds of variables. Images need configuration at two different times. Some values matter only while building (which version of a tool to download, which base image tag to use). Others must be visible to the application when the container runs (PORT, NODE_ENV, PATH). Docker separates these: ARG defines build-time variables, ENV defines environment variables that are stored in the image and present at runtime.
Analogy. ARG is the note you scribble while cooking ("use 200 g of sugar this time"); it is thrown away once the cake is done. ENV is the label printed on the box; everyone who opens the box later can read it.
Comparison
| Aspect | ARG | ENV |
|---|---|---|
| Available during build | Yes, after its declaration | Yes, after its declaration |
| Available in running containers | No | Yes |
| Set from outside | docker build --build-arg NAME=value | docker run -e NAME=value (runtime override) |
| Default value | ARG NAME=default (optional) | Required: ENV NAME=value |
| Scope | Current stage only; a global ARG (before FROM) must be re-declared inside a stage to be used there | Current stage and every image built FROM it |
Visible in docker image history | Yes, for steps that use it | Yes, and in docker image inspect |
| Suitable for secrets | No | No |
If an ARG and an ENV have the same name, the ENV value wins for later instructions.
Common pattern: ARG feeds ENV. When a build-time choice must also be visible at runtime, pass it through:
ARG APP_VERSION=dev
# now persisted in the image config
ENV APP_VERSION=${APP_VERSION}
Predefined build args. Some ARGs exist without declaring them:
| Group | Names | Notes |
|---|---|---|
| Proxy settings | HTTP_PROXY, HTTPS_PROXY, NO_PROXY, FTP_PROXY, ALL_PROXY (and lowercase) | Excluded from docker history unless you declare them with ARG |
| Platform (BuildKit) | TARGETPLATFORM, TARGETOS, TARGETARCH, TARGETVARIANT, BUILDPLATFORM, BUILDOS, BUILDARCH | Declare ARG TARGETARCH (without a value) inside a stage to use it; see Multi-Architecture Images |
Example
# syntax=docker/dockerfile:1
# global: only usable in FROM lines
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
# re-declare (no value) to use it inside the stage
ARG NODE_VERSION
# no default: empty unless passed with --build-arg
ARG BUILD_DATE
# filled automatically by BuildKit
ARG TARGETARCH
ENV NODE_ENV=production \
PORT=8080
RUN echo "node ${NODE_VERSION} for ${TARGETARCH}, built ${BUILD_DATE}" > /build-info.txt
# Only the ENV values exist in the running container
CMD ["sh", "-c", "echo NODE_ENV=$NODE_ENV PORT=$PORT BUILD_DATE=${BUILD_DATE:-unset}"]
docker build --build-arg BUILD_DATE="$(date -u +%FT%TZ)" -t envarg .
docker run --rm envarg # NODE_ENV=production PORT=8080 BUILD_DATE=unset
docker run --rm -e PORT=9090 envarg # runtime override of an ENV value
docker image history --no-trunc envarg | grep BUILD_DATE # the ARG value is recorded!
Never use either for secrets. ENV values are stored in the image config, and ARG values used in a RUN are recorded in the image history and build metadata. Use BuildKit secret mounts for build-time secrets (Build-Time Secrets) and runtime injection for runtime secrets.
Key takeaways
ARGis for build time only;ENVis persisted in the image and visible at runtime.- A global
ARGbeforeFROMmust be re-declared inside each stage that uses it. ENVis inherited by child images and can be overridden withdocker run -e.- Promote a build arg to runtime with
ARG Xfollowed byENV X=${X}. - Both are visible in image metadata or history: never put secrets in them.
🧪 Practice
- (Beginner) Build the example above and verify which variables exist in the container with
docker run --rm envarg env.- (Intermediate) Build an image with
--build-arg TOKEN=abc123used in aRUNstep, then find the token using onlydocker image historyanddocker image inspect. Rewrite the Dockerfile so the token never appears.- Interview question: "What is the difference between
ARGandENV, and why does anARGdefined aboveFROMappear to be empty inside the stage?" Hint: think about build stages as separate scopes and where global args are visible.
<a id="52-runtime-instructions"></a>
5.2 Runtime Instructions
This section covers the instructions that do not change the filesystem but define how containers created from the image behave: what they run, as whom, how they are probed, and how they stop.
CMD
Why CMD. An image is inert until something tells Docker what process to start. Users should be able to type docker run myapp without knowing the command line of the application inside. CMD stores that default command in the image config, while still letting users replace it easily.
Analogy. CMD is the default channel a TV turns on to. It is convenient, but anyone holding the remote (the docker run arguments) can switch to something else.
Three forms
| Form | Example | Meaning |
|---|---|---|
| Exec form (preferred) | CMD ["nginx", "-g", "daemon off;"] | Run the executable directly as PID 1 |
| Shell form | CMD nginx -g "daemon off;" | Run /bin/sh -c "nginx -g ..." |
| Parameter form | CMD ["--port", "8080"] | Default arguments to the ENTRYPOINT |
Rules
- Only the last
CMDin a Dockerfile (in the final stage) takes effect. - Any arguments after the image name in
docker runreplaceCMDentirely; they are not appended. CMDis inherited from the base image. That is whyFROM python:3.13-slimalone starts a Python REPL: the base image setsCMD ["python3"].- Setting
ENTRYPOINTin your Dockerfile resets an inheritedCMDto empty; you must setCMDagain after it if you need one. CMDruns at container start, not at build time. Confusing it withRUNis a classic beginner mistake.
Example
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
# overrides inherited CMD ["python3"]
CMD ["python", "-m", "http.server", "9000"]
# last one wins: this is the default
CMD ["python", "app.py"]
docker build -t cmd-demo .
docker run --rm cmd-demo # runs: python app.py
docker run --rm cmd-demo python --version # CMD replaced: python --version
docker run --rm -it cmd-demo bash # CMD replaced: interactive shell
# See the stored default
docker image inspect cmd-demo --format '{{json .Config.Cmd}}' # ["python","app.py"]
Key takeaways
CMDsets the default command (or default arguments toENTRYPOINT) for containers.- It is fully replaced by any arguments given after the image name in
docker run. - Only the last
CMDcounts, and it is inherited from base images. - Defining
ENTRYPOINTclears the inheritedCMD. - Prefer the exec form.
🧪 Practice
- (Beginner) Build an image from
alpine:3.20withCMD ["echo", "hello"]. Run it with no arguments, then withecho bye, then withls /.- (Intermediate) Create an image
FROM python:3.13-slimthat only addsENTRYPOINT ["python"]. Run it without arguments and inspect.Config.Cmd. Explain why it does not behave like the base image.- Interview question: "What is the difference between
RUNandCMD?" Hint: think about when each executes and what each produces (a layer versus a config entry).
ENTRYPOINT
Why ENTRYPOINT. Some images are meant to behave like a single executable: a CLI tool, a database server, or an application with a startup script. For those, you want the program to be fixed and only its arguments to vary, so that docker run mytool --help runs mytool --help. ENTRYPOINT defines that fixed part; CMD then supplies default arguments that users can override.
Analogy. ENTRYPOINT is a coffee machine; CMD is the default button ("espresso"). Users can press another button (docker run image latte), but they always get coffee from that machine unless they explicitly replace the machine (--entrypoint).
How ENTRYPOINT and CMD combine. The final command is ENTRYPOINT + CMD, but only when both use the exec form:
No ENTRYPOINT | ENTRYPOINT exec_ep p1 (shell form) | ENTRYPOINT ["exec_ep", "p1"] (exec form) | |
|---|---|---|---|
No CMD | Error: nothing to run | /bin/sh -c exec_ep p1 | exec_ep p1 |
CMD ["exec_cmd", "c1"] | exec_cmd c1 | /bin/sh -c exec_ep p1 (CMD ignored) | exec_ep p1 exec_cmd c1 |
CMD exec_cmd c1 | /bin/sh -c exec_cmd c1 | /bin/sh -c exec_ep p1 (CMD ignored) | exec_ep p1 /bin/sh -c exec_cmd c1 |
Takeaway from the table: use the exec form for ENTRYPOINT, and use CMD in exec form for default arguments. The shell form of ENTRYPOINT silently ignores both CMD and docker run arguments.
The entrypoint script pattern. Many official images (postgres, mysql, redis) use a shell script as the entrypoint to do setup work at startup and then hand over control to the real process:
docker run postgres:16
|
v
ENTRYPOINT ["docker-entrypoint.sh"] CMD ["postgres"]
|
v
docker-entrypoint.sh postgres
- initialize data directory if empty
- create user/database from env vars
- exec "$@" ---> replaces the script process with "postgres" (becomes PID 1)
The exec "$@" line is essential: without exec, the shell stays as PID 1 and the application never receives stop signals directly.
Example
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN apk add --no-cache curl
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# default arguments for the entrypoint
CMD ["curl", "--help"]
#!/bin/sh
# entrypoint.sh
set -e
# Setup step: runs on every container start
if [ -n "$API_TOKEN_FILE" ]; then
export API_TOKEN="$(cat "$API_TOKEN_FILE")"
fi
# Allow "docker run image -X GET ..." as a shorthand for curl options
if [ "${1#-}" != "$1" ]; then
set -- curl "$@"
fi
exec "$@" # replace this shell with the final command (PID 1, receives signals)
docker build -t fetch .
docker run --rm fetch # entrypoint.sh curl --help
docker run --rm fetch -sI https://example.com # entrypoint.sh -sI ... -> curl -sI ...
docker run --rm fetch sh -c 'echo custom' # entrypoint.sh sh -c ... (setup still runs)
docker run --rm --entrypoint sh fetch -c 'id' # bypasses the script entirely
Key takeaways
ENTRYPOINTfixes the executable;CMDprovides overridable default arguments.- Use the exec form for both; shell-form
ENTRYPOINTignoresCMDand runtime arguments. - Entrypoint scripts perform startup setup and must end with
exec "$@". docker run --entrypointreplaces the entrypoint (and clears the imageCMD); see Entrypoint and Command Overrides.
🧪 Practice
- (Beginner) Build an image with
ENTRYPOINT ["echo", "Hello"]andCMD ["world"]. Run it with no arguments and withDocker. Explain the outputs.- (Intermediate) Change the example's
exec "$@"to just"$@". Run the container, then rundocker topon it andtime docker stopit. Compare with the original version and explain the difference.- Interview question: "When would you use
ENTRYPOINTinstead ofCMD, and how do they interact?" Hint: think about tool-like images versus general-purpose images, and the exec-form concatenation rule.
Shell vs Exec Form
Why the form matters. RUN, CMD, and ENTRYPOINT each accept two syntaxes that look like cosmetic variants but behave very differently. The difference decides whether a shell is involved, which in turn decides whether variables are expanded, whether pipes work, and, most importantly for CMD/ENTRYPOINT, which process becomes PID 1 and receives signals.
The two forms
# Shell form: a string, executed as /bin/sh -c "<string>"
CMD python app.py --port $PORT
# Exec form: a JSON array, executed directly with execve()
CMD ["python", "app.py", "--port", "8000"]
| Aspect | Shell form | Exec form |
|---|---|---|
| Syntax | Plain string | JSON array with double quotes |
| Executed as | /bin/sh -c "..." (or the SHELL setting) | The binary directly |
$VAR expansion, pipes, &&, globs | Yes | No (no shell) |
| PID 1 in the container | Usually sh | Your application |
docker stop (SIGTERM) | Received by sh, typically not forwarded | Received by your application |
Works in images without a shell (scratch, distroless) | No | Yes |
Combines with CMD arguments (for ENTRYPOINT) | No | Yes |
The PID 1 and signals problem.
Shell form Exec form
---------- ---------
PID 1 /bin/sh -c "python app.py" PID 1 python app.py
| ^
+-- PID 7 python app.py | SIGTERM delivered directly
| -> graceful shutdown
docker stop -> SIGTERM to PID 1 (sh)
sh does not forward it; python keeps running
after 10 s: SIGKILL -> abrupt termination
Some shells replace themselves with the command when given a single simple command, which hides the problem in some images, but you should not rely on that.
Common mistakes
| Mistake | Result | Fix |
|---|---|---|
CMD ['python', 'app.py'] (single quotes) | Not valid JSON, so Docker treats it as shell form and runs the literal string | Always use double quotes |
CMD ["echo", "$HOME"] | Prints $HOME literally | CMD ["sh", "-c", "echo $HOME"] or shell form |
Shell-form CMD in a distroless image | exec: "/bin/sh": not found | Exec form |
| Need a variable and correct signals | Shell form breaks signals | CMD ["sh", "-c", "exec python app.py --port \"$PORT\""] |
The SHELL instruction changes the shell used by shell-form instructions, for example to enable pipefail in bash or to use PowerShell on Windows:
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
Example
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
ENV PORT=8000
# Variable needed at runtime AND proper signal handling:
# sh expands $PORT, then exec replaces sh with python (python becomes PID 1)
CMD ["sh", "-c", "exec python app.py --port \"$PORT\""]
docker run -d --name forms myapp
docker exec forms ps -o pid,args # PID 1 should be python, not sh
time docker stop forms # should return quickly, not after ~10 s
# Alternative: add a tiny init that forwards signals and reaps zombies
docker run -d --init --name forms2 myapp
Key takeaways
- Shell form wraps the command in
/bin/sh -c; exec form runs it directly. - Use exec form for
CMDandENTRYPOINTso your application is PID 1 and receivesSIGTERM. - Exec form must be valid JSON with double quotes; otherwise it is silently treated as shell form.
- Need variable expansion? Use
["sh", "-c", "exec ..."]. - Shell form is fine, and usually preferable, for
RUN.
🧪 Practice
- (Beginner) Build two images that run
sleep 300, one with shell-formCMDand one with exec-formCMD. Comparedocker topoutput for both.- (Intermediate) Time
docker stopon both containers from exercise 1 and explain the difference. Then fix the slow one without switching to the exec form.- Interview question: "Your containers always take exactly 10 seconds to stop and your application never logs its graceful-shutdown message. What is the likely cause?" Hint: think about which process is PID 1 and what happens to
SIGTERMthere.
EXPOSE
Why EXPOSE. Someone running your image needs to know which ports the application listens on. EXPOSE records this in the image metadata as documentation for humans and tools. It does not open, publish, or firewall anything.
Analogy. EXPOSE is the "entrance on the left" sign on a building. It tells visitors where the door is, but it does not unlock it. Publishing with -p is what actually connects the street to that door.
Syntax
# TCP is the default
EXPOSE 8080
EXPOSE 53/udp
EXPOSE 443/tcp 443/udp
What EXPOSE does and does not do
| Action | Does EXPOSE matter? |
|---|---|
Documenting ports in docker image inspect and in docker ps (8080/tcp) | Yes |
Publishing with docker run -P (publish all exposed ports to random host ports) | Yes, -P uses exactly the exposed list |
Publishing with docker run -p 8080:8080 | No, works for any port, exposed or not |
| Container-to-container traffic on a shared network | No, all ports are reachable within the network |
| Making the application listen on that port | No, the application decides that |
Example
# syntax=docker/dockerfile:1
FROM nginx:alpine
EXPOSE 80
docker build -t site .
# EXPOSE alone: nothing is reachable from the host
docker run -d --name s1 site
docker port s1 # (no output)
# -P publishes every exposed port to a random high host port
docker run -d -P --name s2 site
docker port s2 # 80/tcp -> 0.0.0.0:32768
# -p works regardless of EXPOSE
docker run -d -p 8081:80 --name s3 site
docker image inspect site --format '{{json .Config.ExposedPorts}}' # {"80/tcp":{}}
Key takeaways
EXPOSEis metadata: it documents which ports the application uses.- It does not publish ports; use
-por-Pat runtime (see Port Publishing). -Ppublishes all exposed ports to random host ports.- Containers on the same network can reach each other's ports whether or not they are exposed.
🧪 Practice
- (Beginner) Run
nginx:alpinewith-Pand find which host port was chosen. Then do the same with an image that does not expose any port.- (Intermediate) Build an image that
EXPOSEs 8080 but whose application listens on 3000. Publish with-Pand with-p 8080:3000. Explain which one works and why.- Interview question: "Is
EXPOSEa security control?" Hint: consider what actually controls reachability from the host and from other containers.
USER
Why USER. By default, processes in a container run as root (UID 0). The container is isolated, but root inside the container is still root on the host kernel. If an attacker escapes the container, or if a writable host path is mounted, root privileges make the damage much worse. USER makes the image run as an unprivileged account by default, applying the principle of least privilege. The broader topic is covered in Running as Non-Root.
Syntax and behavior
USER <user>[:<group>]
USER <UID>[:<GID>]
- Applies to every following
RUN,CMD, andENTRYPOINT, and to the running container. - Does not affect
COPY/ADD: copied files stayroot:rootunless you use--chown. - A name must exist in the image's
/etc/passwd; a numeric UID works even without an entry. - Can be switched several times; usually you install as root, then switch to the app user near the end.
- Overridable at runtime with
docker run -u(see Working Directory and User). - Prefer a numeric UID in the final
USER: orchestrators such as Kubernetes can only verifyrunAsNonRootfor numeric users.
Creating a user
| Base | Command |
|---|---|
| Debian/Ubuntu | groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid app --no-create-home app |
| Alpine (BusyBox) | addgroup -S -g 10001 app && adduser -S -u 10001 -G app -H app |
| Distroless | Use the :nonroot tag (UID 65532) |
| Official images | Many already ship a user (node in node, nginx in nginx) |
Example
# syntax=docker/dockerfile:1
FROM python:3.13-slim
# 1. Root-only work first
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home app
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 2. Application files owned by the app user (only if it must write to them)
COPY --chown=app:app . .
# 3. Drop privileges for runtime (numeric form for orchestrators)
USER 10001:10001
# non-privileged port; no root needed to bind
EXPOSE 8000
CMD ["python", "app.py"]
docker run --rm myapp id # uid=10001(app) gid=10001(app)
docker run --rm -u 0 myapp id # runtime override (avoid in production)
Common pitfalls
- The application cannot write to its directory because the files were copied as root. Use
--chown, or better, keep code read-only and write only to dedicated directories or volumes. - A bind-mounted host directory is owned by a different UID; match UIDs or adjust permissions.
- Binding ports below 1024 as non-root: Docker allows it inside containers by default (the
net.ipv4.ip_unprivileged_port_startsysctl is set to 0), but not with--network host. Using ports such as 8080 avoids the question.
Key takeaways
- Containers run as root unless the image or the runtime says otherwise.
USERsets the default user for later build steps and for the container.- Install as root, then switch to a dedicated, numeric, unprivileged user.
COPYignoresUSER; set ownership explicitly with--chownwhen needed.
🧪 Practice
- (Beginner) Run
docker run --rm python:3.13-slim idanddocker run --rm node:22-slim id. Then find which usernode:22-slimprovides withdocker run --rm node:22-slim getent passwd node.- (Intermediate) Add
USER 10001to an image and run it with a bind mount of a host directory owned by your user. Try to create a file in it, explain the result, and fix it without running as root.- Interview question: "Why should production images specify a numeric
USER, and what risks remain if a container runs as root?" Hint: think about container escapes, mounted host paths, KubernetesrunAsNonRoot, and user namespaces.
HEALTHCHECK
Why HEALTHCHECK. "The process is running" is not the same as "the service works". A web server can be deadlocked, a database can still be replaying its log, or an application can have lost its connection pool, all while the process stays alive. HEALTHCHECK defines a command that Docker runs periodically inside the container to test whether the service actually responds, and exposes the result as a health status.
Analogy. A process status check is seeing that the lights are on in a shop. A health check is walking in and asking for something; only then do you know whether the shop is really serving customers.
Syntax and options
HEALTHCHECK [OPTIONS] CMD <command>
# disable a check inherited from the base image
HEALTHCHECK NONE
| Option | Default | Meaning |
|---|---|---|
--interval | 30s | Time between checks |
--timeout | 30s | A check taking longer counts as failed |
--start-period | 0s | Grace period at startup during which failures do not count toward retries |
--start-interval | 5s | Interval used during the start period (Docker Engine 25+) |
--retries | 3 | Consecutive failures needed to become unhealthy |
The command's exit code decides the result: 0 healthy, 1 unhealthy (2 is reserved; do not use it).
Health states
container starts
|
v
+-----------+ check succeeds +-----------+
| starting | -----------------> | healthy |
+-----------+ +-----------+
| | ^
retries failures| retries failures| | one success
(after start v v |
period) +-------------+ <----------------+ |
| unhealthy | ------------------------+
+-------------+
Who uses the status
| Consumer | Behavior |
|---|---|
docker ps | Shows (healthy), (unhealthy), (health: starting) |
| Docker Compose | depends_on: { db: { condition: service_healthy } } waits for it |
| Docker Swarm | Replaces unhealthy tasks and gates rolling updates |
Plain docker run | Only reports; it does not restart unhealthy containers |
| Kubernetes | Ignores HEALTHCHECK; uses its own liveness/readiness probes |
Example
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
EXPOSE 8000
# The check runs inside the container, so the tool must exist in the image.
# python is already present; no need to install curl just for this.
# urlopen raises on connection errors and non-2xx responses -> exit code 1.
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
CMD ["python", "app.py"]
docker run -d --name api myapi
docker ps --filter name=api --format '{{.Names}}: {{.Status}}' # Up 5 seconds (health: starting)
# Status and the last check outputs
docker inspect api --format '{{.State.Health.Status}}'
docker inspect api --format '{{json .State.Health.Log}}' | jq '.[-1]'
# Runtime override or disabling
docker run -d --health-cmd 'wget -qO- http://127.0.0.1:8000/healthz || exit 1' --health-interval 10s myapi
docker run -d --no-healthcheck myapi
Writing good checks
- Check something meaningful and cheap: a
/healthzendpoint,pg_isready,redis-cli ping. - Keep it local to the container; do not fail because a downstream dependency is down, or you will cascade restarts.
- Make sure the tool exists in the image (slim and distroless images often lack
curl). - Use
--start-periodfor slow-starting services instead of very long intervals.
Key takeaways
HEALTHCHECKtells Docker how to test whether the service inside the container actually works.- Exit code
0means healthy,1unhealthy; states arestarting,healthy,unhealthy. - Docker reports health but does not restart containers on its own; Compose and Swarm act on it.
- Keep checks cheap, local, and runnable with tools present in the image.
🧪 Practice
- (Beginner) Run
postgres:16with--health-cmd 'pg_isready -U postgres' --health-interval 5sand watch the status change withdocker ps.- (Intermediate) Write a Compose file where an API waits for a database with
condition: service_healthy. Break the database health check on purpose and observe the startup behavior.- Interview question: "A container shows
(unhealthy)but keeps running and receiving traffic. Why, and what would you change?" Hint: think about which component is responsible for acting on health status in plain Docker, Compose, Swarm, and Kubernetes.
VOLUME
Why VOLUME. Some paths in an image hold data that must outlive the container or that is written heavily, such as a database's data directory. VOLUME marks such a path as a mount point. If the user does not mount anything there, Docker automatically creates an anonymous volume, so the data bypasses the copy-on-write container layer (see Copy-on-Write) and is not lost silently when the container is removed.
What happens at runtime
Dockerfile: VOLUME /var/lib/postgresql/data
docker run postgres:16 docker run -v pgdata:/var/lib/postgresql/data postgres:16
-> new ANONYMOUS volume -> your NAMED volume is mounted
(random 64-char name) (and reused across containers)
In both cases, if the volume is empty, the image's content at that path is
copied into it the first time it is mounted.
Rules and caveats
- You cannot specify a host path in a Dockerfile; that decision belongs to whoever runs the container.
- Changes made to the path by later build steps are discarded by the legacy builder but kept by BuildKit. Prepare the directory's content before declaring the
VOLUMEso the result is the same with either builder. - A child image cannot "un-declare" a
VOLUMEinherited from its base. - Anonymous volumes accumulate: every
docker runwithout--rmcreates a new one. Clean them up withdocker volume prune. - Volumes are covered in depth in chapter 6.
Example
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN mkdir -p /data \
&& echo "seed" > /data/seed.txt \
&& chown -R 10001:10001 /data # prepare content and ownership BEFORE declaring the volume
VOLUME ["/data"]
USER 10001
CMD ["sh", "-c", "date >> /data/log.txt; cat /data/log.txt"]
docker build -t vol-demo .
# Anonymous volume: each run gets a fresh copy of the seed data
docker run --name v1 vol-demo
docker inspect v1 --format '{{range .Mounts}}{{.Type}} {{.Name}} -> {{.Destination}}{{end}}'
# Named volume: data accumulates across containers
docker run --rm -v logs:/data vol-demo
docker run --rm -v logs:/data vol-demo # two lines of log now
docker rm -v v1 # -v also removes its anonymous volume
docker image inspect vol-demo --format '{{json .Config.Volumes}}' # {"/data":{}}
Key takeaways
VOLUMEdeclares a mount point for persistent or write-heavy data.- Without an explicit mount, Docker creates an anonymous volume, seeded from the image content if empty.
- Host paths can only be chosen at runtime, never in the Dockerfile.
- Populate the directory before the
VOLUMEline; derived images cannot remove the declaration. - Prefer explicit named volumes at runtime; prune anonymous ones regularly.
🧪 Practice
- (Beginner) Run
postgres:16three times without-v(settingPOSTGRES_PASSWORD), stop and remove the containers without-v, and then list volumes. Explain what you see.- (Intermediate) Build an image that declares
VOLUME /dataand then runsRUN echo hi > /data/after.txt. Check whether the file exists in a container, then rebuild withDOCKER_BUILDKIT=0(if the legacy builder is available) and compare.- Interview question: "Why do some teams avoid
VOLUMEin their Dockerfiles even for stateful services?" Hint: think about anonymous volume sprawl, the inability to override it in child images, and who should decide storage.
LABEL
Why LABEL. Once you have hundreds of images in registries and on hosts, questions arise: which Git commit built this image, who owns it, which license applies, which ones can be cleaned up? LABEL attaches arbitrary key-value metadata to the image config so that humans, scanners, registries, and scripts can answer these questions without guessing from tags.
Syntax
LABEL key="value" other.key="value with spaces"
- Labels are inherited from base images; a later label with the same key overrides the earlier value.
LABELonly changes the config and does not create a layer.- Use reverse-DNS prefixes for your own keys (
com.acme.team) to avoid collisions. - The deprecated
MAINTAINERinstruction is replaced by a label.
Standard OCI annotation keys. These keys are understood by registries (GHCR links images to repositories through source), scanners, and many tools:
| Key | Meaning |
|---|---|
org.opencontainers.image.title | Human-readable name |
org.opencontainers.image.description | Short description |
org.opencontainers.image.version | Version of the packaged software |
org.opencontainers.image.revision | Source control revision (commit SHA) |
org.opencontainers.image.source | URL of the source repository |
org.opencontainers.image.created | Build date (RFC 3339) |
org.opencontainers.image.licenses | SPDX license expression |
org.opencontainers.image.authors | Contact details |
Example
# syntax=docker/dockerfile:1
FROM node:22-slim
ARG GIT_SHA=unknown
ARG BUILD_DATE
ARG VERSION=0.0.0-dev
LABEL org.opencontainers.image.title="acme-web" \
org.opencontainers.image.source="https://github.com/acme/web" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.licenses="Apache-2.0" \
com.acme.team="payments"
# ... rest of the Dockerfile
docker build \
--build-arg GIT_SHA="$(git rev-parse HEAD)" \
--build-arg BUILD_DATE="$(date -u +%FT%TZ)" \
--build-arg VERSION=1.4.2 \
--label com.acme.ci-run=1234 \
-t acme-web:1.4.2 .
# (--label adds labels from the CLI without editing the Dockerfile)
docker image inspect acme-web:1.4.2 --format '{{json .Config.Labels}}' | jq
docker image ls --filter label=com.acme.team=payments
docker image prune -a --filter label=com.acme.ci-run # clean up only CI images
Key takeaways
LABELstores key-value metadata in the image config without adding a layer.- Use the
org.opencontainers.image.*keys for interoperability; prefix custom keys with reverse DNS. - Labels are inherited and can be added with
docker build --label. - Filter
docker image ls,docker ps, and prune commands by label.
🧪 Practice
- (Beginner) Inspect the labels of
nginx:alpineandnode:22-slim. Which keys do they set, and which come from their own base images?- (Intermediate) Add OCI labels with the current Git commit to one of your images in a build script, then write a one-line command that prints the commit of any running container.
- Interview question: "Given a running container in production, how would you determine exactly which source code it was built from?" Hint: think about labels such as
revisionandsource, image digests, and build provenance attestations.
STOPSIGNAL
Why STOPSIGNAL. docker stop sends SIGTERM to PID 1, waits for a grace period (10 seconds by default), and then sends SIGKILL. Most applications treat SIGTERM as "shut down gracefully", but not all of them. Some servers use a different signal for a graceful stop, and treat SIGTERM as "stop immediately". STOPSIGNAL records the right signal in the image so that every docker stop triggers the intended shutdown.
Syntax
# by name
STOPSIGNAL SIGQUIT
# or by number
STOPSIGNAL 3
Real-world examples
| Software | Graceful stop signal | Behavior on SIGTERM |
|---|---|---|
| nginx | SIGQUIT (official image sets it) | Fast shutdown, drops open connections |
| Apache httpd | SIGWINCH (official image sets it) | Immediate stop of children |
| PHP-FPM | SIGQUIT | Immediate termination |
| Most apps (Node, Python, Go, Java) | SIGTERM (default) | Graceful, if the app handles it |
Stop sequence
docker stop web
|
+--> send STOPSIGNAL (default SIGTERM) to PID 1
| app drains connections, flushes data, exits
|
+--> wait up to --stop-timeout / -t seconds (default 10)
|
+--> still running? send SIGKILL (cannot be caught)
Example
# syntax=docker/dockerfile:1
FROM nginx:alpine
COPY site/ /usr/share/nginx/html/
# already inherited from nginx:alpine; explicit for clarity
STOPSIGNAL SIGQUIT
docker image inspect nginx:alpine --format '{{.Config.StopSignal}}' # SIGQUIT
# Runtime overrides
docker run -d --name web --stop-signal SIGTERM --stop-timeout 30 mysite
docker stop -t 60 web # longer grace period for this stop
docker kill -s SIGHUP web # send an arbitrary signal (e.g. reload config)
STOPSIGNAL only helps if the signal reaches your application: with a shell-form CMD, it goes to sh instead (see Shell vs Exec Form).
Key takeaways
STOPSIGNALsets the signaldocker stopsends first; the default isSIGTERM.- Use it when the application's graceful-shutdown signal is not
SIGTERM(nginx, httpd, PHP-FPM). - After the timeout, Docker always falls back to
SIGKILL. - It is overridable with
--stop-signal, and only works if PID 1 is your application.
🧪 Practice
- (Beginner) Inspect the stop signal of
nginx:alpine,httpd:2.4, andpython:3.13-slim.- (Intermediate) Write a small script that traps
SIGTERMandSIGUSR1and prints different messages. Build two images with differentSTOPSIGNALs and observe which messagedocker stopproduces indocker logs.- Interview question: "During deployments, users of an nginx-based image see dropped connections. How would you investigate and fix that at the image level?" Hint: consider the stop signal, the stop timeout, and whether nginx is PID 1.
<a id="53-the-build-process"></a>
5.3 The Build Process
This section explains what happens when you run docker build: what gets sent to the builder, how the cache decides which steps to reuse, and how to pass parameters into a build.
docker build
Why understand the command. docker build looks like a single step, but it involves a client, a builder (BuildKit), a build context, a Dockerfile, a cache, and an output destination. Knowing these parts and the flags that control them is what lets you build the right stage, for the right platform, with the right tag, and debug a build when it misbehaves.
Architecture
docker CLI (buildx) BuildKit (in dockerd or a separate builder)
+-------------------+ context files +--------------------------------------+
| docker build ... | --------------------> | 1. parse Dockerfile (frontend) |
| -t, -f, --target | Dockerfile | 2. turn it into a graph of steps |
| --build-arg ... | --------------------> | 3. resolve base images (pull) |
+-------------------+ | 4. execute steps, reuse cache |
^ | 5. export result (image, files, push) |
| progress + result +--------------------------------------+
+---------------------------------------------------+
Since Docker Engine 23, BuildKit is the default builder and docker build is an alias for docker buildx build. The older "legacy builder" is deprecated. BuildKit runs independent steps in parallel, skips stages that the target does not need, and caches more efficiently.
Syntax
docker build [OPTIONS] PATH | URL | -
The positional argument is the build context (see Build Context): a local directory, a Git URL, a tarball URL, or - to read from stdin.
Most-used options
| Option | Purpose |
|---|---|
-t, --tag name:tag | Tag the result; can be repeated for several tags |
-f, --file path | Use a Dockerfile other than PATH/Dockerfile |
--build-arg K=V | Set a build argument (see Build Arguments) |
--target stage | Build up to a specific stage (see Targeting Specific Stages) |
--pull | Always check for a newer version of the base images |
--no-cache | Ignore the cache for every step |
--progress plain | Show the full output of every step (useful for debugging) |
--platform linux/arm64 | Build for another platform (see Multi-Architecture Images) |
--load / --push | Load the result into the local image store / push it to a registry |
-o, --output type=local,dest=dir | Export the resulting filesystem as files instead of an image |
--label K=V | Add labels without editing the Dockerfile |
--check | Run build checks (lint) only, without building |
Example
# Most common: Dockerfile in the current directory, context = current directory
docker build -t myapp:1.0 .
# Several tags at once, a Dockerfile in another place, fresh base image
docker build -f docker/Dockerfile.prod --pull -t myapp:1.0 -t myapp:latest .
# Full, uncollapsed logs of every step (debugging)
docker build --progress=plain --no-cache -t myapp:debug .
# Build directly from a Git repository (ref and subdirectory after '#')
docker build -t tool https://github.com/acme/tool.git#v2.0.0:cli
# Dockerfile from stdin, no context at all (COPY is impossible)
docker build -t tiny - <<'EOF'
FROM alpine:3.20
CMD ["echo", "built from stdin"]
EOF
# Export a build result as plain files (e.g. a compiled binary)
docker build --target artifact -o type=local,dest=./dist .
Reading the output
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load metadata for docker.io/library/python:3.13-slim 0.6s
=> [internal] load .dockerignore 0.0s
=> [1/5] FROM docker.io/library/python:3.13-slim@sha256:... 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 12.40kB 0.0s <- context size
=> CACHED [2/5] WORKDIR /app 0.0s <- cache hit
=> CACHED [3/5] COPY requirements.txt . 0.0s
=> CACHED [4/5] RUN pip install -r requirements.txt 0.0s
=> [5/5] COPY . . 0.1s <- re-executed
=> exporting to image 0.1s
Key takeaways
docker buildsends a context and a Dockerfile to BuildKit, which executes the steps and exports the result.- BuildKit is the default builder;
docker buildisdocker buildx build. -t,-f,--build-arg,--target,--pull, and--no-cachecover most daily use.--progress=plainshows full step output;CACHEDmarks steps reused from the cache.- The output does not have to be an image:
-oexports files.
🧪 Practice
- (Beginner) Build an image with two tags in one command, then list them and verify they have the same image ID.
- (Intermediate) Build a public GitHub repository that contains a Dockerfile directly from its URL, without cloning it. Then build only a subdirectory of a repository using the
#ref:subdirsyntax.- Interview question: "A build behaves differently on your laptop and in CI, even though the Dockerfile is identical. What would you compare?" Hint: think about the context contents, base image freshness (
--pull), the cache, build args, the target platform, and the builder version.
Build Context
Why a context. The builder may run on a different machine than the CLI (a remote daemon, a CI builder, a cloud builder). It cannot read your filesystem directly, so COPY and ADD can only use files that are explicitly handed over to it. That set of files is the build context: the directory (or Git repository, or tarball) you pass as the last argument to docker build.
Analogy. The build context is the box of ingredients you hand to a chef working in another kitchen. The chef can only use what is in the box. Put too little in and the recipe fails; put your whole pantry in and the delivery is slow, and the chef might accidentally use something private.
Key facts
docker build -f docker/Dockerfile -t app .
^^^^^^^^^^^^^^^^^ ^
Dockerfile location context root
(can be anywhere) (COPY paths are relative to this)
project/ context = project/
+-- docker/Dockerfile COPY src/ /app/src/ OK
+-- src/ COPY ../other /x ERROR: outside the context
+-- .git/ (sent unless ignored)
+-- node_modules/ (sent unless ignored)
+-- .env (sent unless ignored!)
COPYandADDpaths are relative to the context root, not to the Dockerfile's location.- Nothing outside the context can be copied;
..cannot escape it. - With BuildKit, the context is transferred lazily and incrementally: only files that the build actually references are sent, and unchanged files are not re-sent on the next build. The legacy builder sent the entire directory on every build.
- Even with BuildKit,
COPY . .references everything in the context, so large or sensitive files still end up in the image unless excluded by.dockerignore.
Context types
| Context | Example | Notes |
|---|---|---|
| Local directory | docker build . | The common case |
| Git repository | docker build https://github.com/org/repo.git#main:dir | Cloned by the builder |
| Remote tarball | docker build https://example.com/ctx.tar.gz | Downloaded and extracted |
| None (stdin Dockerfile) | docker build - < Dockerfile | No COPY from context possible |
| Additional named contexts | --build-context assets=../shared/assets | Referenced with COPY --from=assets |
Example
# How big is the context? Watch the "transferring context" line.
docker build --progress=plain -t app . 2>&1 | grep 'transferring context'
# Dockerfile and context in different places: paths in COPY refer to ./service
docker build -f build/service.Dockerfile ./service
# Give the build access to a sibling directory without widening the main context
docker build --build-context shared=../shared-config -t app .
# syntax=docker/dockerfile:1
FROM nginx:alpine
# Comes from the main context (./site in the directory passed to docker build)
COPY site/ /usr/share/nginx/html/
# Comes from the named context "shared" (../shared-config)
COPY --from=shared nginx.conf /etc/nginx/conf.d/default.conf
Key takeaways
- The build context is the set of files the builder can see;
COPY/ADDpaths are relative to it. - The Dockerfile location (
-f) and the context root are independent. - Files outside the context are unreachable; use a wider context or a named
--build-context. - Keep contexts small and clean with
.dockerignore, both for speed and to avoid leaking secrets.
🧪 Practice
- (Beginner) Create a 500 MB file in a project directory (
fallocate -l 500M big.binordd) and build an image whose Dockerfile usesCOPY . .. Note the context transfer and image size, then repeat with a Dockerfile that only copies one small file.- (Intermediate) Restructure a monorepo build so that
services/api/Dockerfilecan copylibs/common/without moving the Dockerfile, using once a wider context and once--build-context.- Interview question: "Why does
COPY ../config.yml .fail, and what are the correct ways to include that file?" Hint: think about what the builder is allowed to see and how the context can be chosen or extended.
.dockerignore
Why .dockerignore. Project directories contain much more than the application needs: .git history, node_modules, build outputs, IDE settings, local .env files with credentials. Sending these to the builder slows builds down, invalidates the cache whenever an irrelevant file changes, bloats images built with COPY . ., and can leak secrets into image layers. A .dockerignore file excludes paths from the build context, much like .gitignore does for Git.
Location and rules
- Place
.dockerignorein the root of the build context. Alternatively, a Dockerfile-specific ignore file named<Dockerfile name>.dockerignoreplaced next to the Dockerfile (for examplebuild/api.Dockerfile.dockerignore) takes precedence. - One pattern per line;
#starts a comment line. - Patterns use Go's
filepath.Matchsyntax plus**for any number of directories. - Patterns are matched against paths relative to the context root. Unlike
.gitignore,*.logonly matches files in the root; use**/*.logto match at any depth. - Lines starting with
!are exceptions that re-include paths. - The last matching line wins, so order matters.
- The Dockerfile and
.dockerignoreare always sent to the builder, even if listed, but cannot then be copied into the image.
Pattern reference
| Pattern | Matches |
|---|---|
node_modules | ./node_modules at the context root |
**/node_modules | node_modules at any depth |
*.md | Markdown files in the root only |
**/*.md | Markdown files anywhere |
temp? | temp1, tempA in the root |
!README.md | Re-include README.md after a broader exclusion |
* followed by !src and !package*.json | Allow-list: exclude everything except the listed paths |
Example
# .dockerignore
# Version control and tooling
.git
.gitignore
.github
.vscode
.idea
# Dependencies and build outputs (rebuilt inside the image)
**/node_modules
**/__pycache__
**/*.pyc
dist
build
coverage
# Local configuration and secrets: never send them
**/.env
**/.env.*
!**/.env.example
*.pem
*.key
# Docker files themselves are not needed inside the image
Dockerfile*
compose*.yaml
# Logs and OS clutter, at any depth
**/*.log
**/.DS_Store
Allow-list style. For strict control, exclude everything and re-include only what the build needs. New files are then excluded by default, which is safer:
# .dockerignore (allow-list)
*
!src/
!package.json
!package-lock.json
!tsconfig.json
# Verify what actually reaches the builder: list the context as the builder sees it
docker build -q -t ctx-check -f - . <<'EOF'
FROM busybox
COPY . /ctx
CMD ["find", "/ctx", "-maxdepth", "2"]
EOF
docker run --rm ctx-check
Key takeaways
.dockerignoreexcludes files from the build context: faster builds, better caching, smaller images, fewer leaks.- Patterns are relative to the context root; use
**/to match at any depth. !re-includes paths, and the last matching rule wins.- Always exclude
.git, dependency folders, build outputs, and.env/key files. - An allow-list (
*then!entries) is the safest style for production builds.
🧪 Practice
- (Beginner) Add a
.envfile with a fake secret to a project that usesCOPY . .. Build, then find the secret inside the image. Add a.dockerignoreand confirm it is gone.- (Intermediate) Write a
.dockerignorecontaining*.logand place log files at the root and inlogs/. Use the context-check build above to see which are excluded, then fix the pattern.- Interview question: "A team keeps
node_modulesout of Git but not out of the Docker context. What problems can that cause?" Hint: consider context size, cache invalidation, native modules compiled for the host OS, and overwriting the dependencies installed in the image.
Build Cache
Why a cache. Installing dependencies or compiling code can take minutes, while most commits change only a few source files. Rebuilding everything every time would waste enormous amounts of time in development and CI. The build cache stores the result of each step and reuses it when the builder can prove the step would produce the same result.
Analogy. The cache is a cook's prep station. If today's recipe begins with the same chopped onions as yesterday's and the onions have not changed, the cook reuses the prepared bowl. The moment one ingredient differs, that step and everything after it must be done again.
How a cache hit is decided. For each instruction, BuildKit computes a cache key from:
| Instruction | Cache key includes |
|---|---|
FROM | The resolved base image (digest) |
RUN | The parent step's key + the command string + build args/env used + mounts |
COPY / ADD (local) | The parent step's key + a checksum of the copied files' contents and metadata (permissions, ownership), but not modification times |
ADD <url> | The parent step's key + the downloaded content (or the --checksum) |
| Metadata instructions | The parent step's key + the instruction |
The crucial consequence: for RUN, Docker does not look at what the command would download or produce. RUN apt-get update or RUN git clone ... with an unchanged command string is a cache hit even if the remote content changed months ago.
Where the cache lives
- Local cache: stored by the builder on the machine where it runs. On ephemeral CI runners it is lost after each job.
- External cache: exported to and imported from a registry, a local directory, or a CI cache service with
--cache-toand--cache-from(see Registry and Inline Cache).
Example
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
# Changes rarely: these steps are usually CACHED
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Changes on every commit: only this layer and what follows rebuild
COPY . .
CMD ["python", "app.py"]
docker build -t cache-demo . # first build: every step executes
touch app.py && docker build -t cache-demo .
# touch alone changes only the mtime -> COPY . . is still CACHED
echo "# change" >> app.py && docker build -t cache-demo .
# CACHED [3/5] COPY requirements.txt .
# CACHED [4/5] RUN pip install ...
# [5/5] COPY . . <- only the changed step reruns
# Inspect and manage the build cache
docker buildx du # cache usage per record
docker builder prune --filter until=168h # remove cache entries older than 7 days
docker builder prune -a # remove all build cache
# Bypass the cache when you need fresh results
docker build --no-cache -t cache-demo .
docker build --no-cache-filter deps -t app . # in a multi-stage file: only stage "deps"
Key takeaways
- Each step is reused if its cache key matches: parent step + instruction (+ file checksums for
COPY/ADD). RUNis cached by its command text, not by the result; remote changes are not detected.COPYis cached by file content, not modification time.- The cache is local to the builder; CI needs an external cache to benefit across runs.
--no-cache,--no-cache-filter,--pull, anddocker builder prunegive you control.
🧪 Practice
- (Beginner) Build the example twice, then change only a comment in
requirements.txtand rebuild. Which steps rerun?- (Intermediate) Write a Dockerfile with
RUN curl -s https://worldtimeapi.org/api/timezone/Etc/UTC > /time.json(or any changing URL). Rebuild several times and explain why the file never changes. Find two ways to refresh it.- Interview question: "How does Docker decide whether it can reuse a cached layer for a
COPYversus aRUNinstruction?" Hint: compare what goes into the cache key for each: file checksums versus the instruction text.
Cache Invalidation
Why it matters. The cache only helps if it hits. A single misplaced line can make every build reinstall all dependencies, turning a 10-second rebuild into a 5-minute one. Understanding exactly what invalidates the cache, and how invalidation propagates, is what lets you structure Dockerfiles for fast rebuilds.
The cascade rule. Within a stage, steps form a chain. When one step misses the cache, every following step in that stage is rebuilt, even if its own instruction is unchanged, because its parent layer is different.
Step Build 1 Build 2 (app.py changed)
--------------------------- --------- ------------------------------
FROM node:22-slim executed CACHED
WORKDIR /app executed CACHED
COPY . . executed MISS (a file checksum changed)
RUN npm ci executed REBUILT (parent changed) <- slow!
CMD ["node", "server.js"] executed REBUILT (parent changed)
Fixed ordering:
FROM node:22-slim executed CACHED
WORKDIR /app executed CACHED
COPY package*.json ./ executed CACHED (manifests unchanged)
RUN npm ci executed CACHED <- fast
COPY . . executed MISS
CMD ["node", "server.js"] executed REBUILT (cheap)
In multi-stage builds, the cascade is per dependency chain: a miss in one stage does not invalidate an unrelated stage.
What invalidates a step
| Cause | Example |
|---|---|
| Instruction text changed | Adding a package to a RUN apt-get install line |
| Copied file content or permissions changed | Editing any file matched by COPY . . |
| Parent step invalidated | Anything after a missed step |
| Base image changed | --pull fetched a new digest for the tag, or the local tag was updated |
| Build arg value changed | A different --build-arg value: miss at the first step that uses the arg (in practice, the first RUN after its ARG declaration, since RUN sees all declared args as environment) |
--no-cache / --no-cache-filter | Explicit request |
What does not invalidate a step
- Changes to files excluded by
.dockerignoreor not referenced by anyCOPY. - Modification times alone (
touch). - Changes to remote resources fetched by
RUN(this is the "stale cache" problem). - Changes to labels added with
docker build --label(they apply to the final config).
Intentional cache busting
# syntax=docker/dockerfile:1
FROM debian:12-slim
# Stable steps first
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Declare the ARG right before the step it should invalidate, not at the top,
# so a changing value does not invalidate the stable steps above.
ARG CACHEBUST=0
RUN curl -fsSL https://example.com/latest-rules.json -o /etc/rules.json
# Normal build: the download is cached
docker build -t rules .
# Force only the download (and later steps) to rerun
docker build --build-arg CACHEBUST="$(date +%s)" -t rules .
# Pick up a new base image digest for the same tag
docker build --pull -t rules .
Key takeaways
- A cache miss invalidates every following step in the same chain.
- Order steps from least to most frequently changing; copy dependency manifests before source code.
- Changed build args cause a miss at their first use, typically the first
RUNafter theARG. - Remote content fetched by
RUNis not checked; bust the cache deliberately when freshness matters. - Use
--pullfor fresh base images and--no-cacheonly when you need a fully clean build.
🧪 Practice
- (Beginner) Take the "bad" Node ordering from the diagram, build it, change one source file, and time the rebuild. Reorder it and repeat.
- (Intermediate) Move
ARG CACHEBUSTto the top of the example Dockerfile and rebuild with a new value. Explain which steps rerun and why.- Interview question: "Your CI rebuilds all dependencies on every commit even though they rarely change. Walk me through how you would diagnose and fix this." Hint: look at step ordering, broad
COPYinstructions,.dockerignore, build args with changing values (timestamps, commit SHAs), and whether CI has any cache at all.
Build Arguments
Why build arguments. One Dockerfile often needs to produce slightly different images: a different runtime version, a debug or release build, a version string baked into a binary. Copying the Dockerfile for each variant would be unmaintainable. Build arguments are parameters you pass at build time, so the same Dockerfile can produce many variants. The ARG instruction itself was introduced in ENV and ARG; this topic focuses on using arguments in the build workflow.
Passing values
docker build --build-arg VERSION=1.4.2 . # explicit value
VERSION=1.4.2 docker build --build-arg VERSION . # value taken from the client's environment
Scope rules, visualized
ARG BASE_TAG=3.20 <- global scope: visible only to FROM lines
FROM alpine:${BASE_TAG} AS a <- OK
RUN echo $BASE_TAG <- empty! not in scope inside the stage
ARG BASE_TAG <- re-declare inside the stage (inherits global value)
ARG MODE=release <- stage scope: from here to the end of stage "a"
RUN echo $BASE_TAG $MODE <- "3.20 release"
FROM alpine:${BASE_TAG} AS b <- new stage: MODE is gone, must be declared again
Typical uses
| Use | Example |
|---|---|
| Choose base image versions | ARG GO_VERSION=1.23 then FROM golang:${GO_VERSION} |
| Embed build metadata | ARG GIT_SHA used in LABEL and -ldflags |
| Variants | ARG MODE=release to toggle optimization or debug symbols |
| Cross-compilation | Automatic TARGETOS/TARGETARCH args |
| Proxies in corporate networks | Predefined HTTP_PROXY/HTTPS_PROXY |
| Reproducible timestamps | SOURCE_DATE_EPOCH makes BuildKit normalize timestamps |
Example
# syntax=docker/dockerfile:1
ARG GO_VERSION=1.23
FROM golang:${GO_VERSION} AS build
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Bake version info into the binary and build for the requested platform
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${GIT_SHA}" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
docker build \
--build-arg GO_VERSION=1.23 \
--build-arg VERSION=1.4.2 \
--build-arg GIT_SHA="$(git rev-parse --short HEAD)" \
-t app:1.4.2 .
docker run --rm app:1.4.2 --version # app 1.4.2 (a1b2c3d), if the app prints it
Pitfalls
- Undeclared args passed with
--build-argproduce a warning and are ignored. - Args with constantly changing values (timestamps, commit SHAs) invalidate the cache from their first use; declare them as late as possible.
- Values are recorded in the image history and in build provenance: never pass secrets as build args. Use
--secretinstead (see Secret Mounts).
Key takeaways
--build-arg NAME=valueparameterizes a build;ARGdeclares and optionally defaults it.- Global args are only visible to
FROMuntil re-declared inside a stage; each stage has its own scope. - BuildKit provides automatic platform args (
TARGETARCH, ...) that you only need to declare. - Declare frequently changing args late to protect the cache.
- Build args are not secret.
🧪 Practice
- (Beginner) Parameterize an
alpineimage's version with a globalARGand build it for two versions. Runcat /etc/alpine-releasein each.- (Intermediate) Add
ARG GIT_SHAat the very top of a stage that installs dependencies, build twice with different SHAs, and observe the cache. Move the declaration to just before the step that uses it and compare.- Interview question: "Why is passing an API token with
--build-arginsecure even if the final image does not contain the token in any file?" Hint: consider image history, build metadata and provenance, and the CI logs that record build commands.
<a id="54-multi-stage-builds"></a>
5.4 Multi-Stage Builds
This section shows how to use several FROM stages in one Dockerfile to separate building software from running it, producing small and secure runtime images.
Multi-Stage Concept
Why multi-stage builds. Building software needs compilers, package managers, headers, test tools, and caches. Running it usually needs only the compiled output and a few runtime libraries. If both happen in a single image, every production container carries hundreds of megabytes of build tooling: slower pulls, more vulnerabilities to patch, and more tools for an attacker to use. Before multi-stage builds, teams used the "builder pattern": two Dockerfiles and a shell script that built in one image, copied the artifact out with docker cp, and built a second image. Multi-stage builds do the same in a single Dockerfile.
Analogy. A multi-stage build is like a workshop and a showroom. The furniture is built in the workshop, full of saws, glue, and sawdust. Only the finished chair is carried into the showroom; the tools never leave the workshop.
How it works. Every FROM begins a new, independent stage with its own filesystem. A later stage can copy selected files from an earlier one. Only the final stage (or the stage selected with --target) becomes the image; everything else is discarded from the output (but kept in the build cache).
Dockerfile Resulting image
------------------------------------- ------------------------------
FROM golang:1.23 AS build ----+ contains only:
(Go toolchain, ~800 MB) | - distroless static base (~2 MB)
COPY . . | - /app binary (~10 MB)
RUN go build -o /out/app |
| /out/app
FROM gcr.io/distroless/static <---+ does NOT contain:
COPY --from=build /out/app /app - Go compiler, source code,
ENTRYPOINT ["/app"] module cache, shell, git
Single-stage vs multi-stage
| Aspect | Single-stage | Multi-stage |
|---|---|---|
| Final image size | Build tools + app | App + runtime only |
| Attack surface | Compilers, package managers, shell | Minimal |
| Source code in image | Usually yes | No (only artifacts) |
| Number of Dockerfiles | One (or two plus a script for the builder pattern) | One |
| Build cache | One linear chain | Independent chains per stage, built in parallel by BuildKit |
Example
# syntax=docker/dockerfile:1
# ---- Stage 1: build ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/hello ./cmd/hello
# ---- Stage 2: runtime ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hello /hello
ENTRYPOINT ["/hello"]
docker build -t hello:multi .
docker image ls hello # a few MB instead of ~800 MB
docker run --rm hello:multi
Key takeaways
- Each
FROMstarts a separate stage; only the final (or targeted) stage is the output image. - Build tooling stays in earlier stages; only selected artifacts are copied forward.
- Results: smaller images, smaller attack surface, no source code shipped, one Dockerfile.
- BuildKit builds independent stages in parallel and skips stages that the target does not need.
🧪 Practice
- (Beginner) Build a hello-world Go or C program in a single-stage image based on
golangorgcc. Convert it to a multi-stage build and compare the image sizes.- (Intermediate) Add a stage that is never referenced by the final stage (for example one that runs
sleep 30). Build and observe whether BuildKit executes it. Then reference it withCOPY --fromand build again.- Interview question: "What problem do multi-stage builds solve, and what did people do before they existed?" Hint: think about build-time versus runtime dependencies, and the builder pattern with two Dockerfiles and
docker cp.
Builder and Runtime Stages
Why separate roles. The two typical stages have opposite goals. The builder stage optimizes for convenience and caching: full SDK, package manager, compilers, and caches. The runtime stage optimizes for minimalism and security: only what the application needs to start. Designing each stage for its own goal, instead of compromising in one image, is the core skill of multi-stage builds.
Comparison of roles
| Concern | Builder stage | Runtime stage |
|---|---|---|
| Base image | Full SDK (golang, maven, node, python) | Slim, distroless, or scratch |
| Contents | Source, dependencies, compilers, test tools | Artifact, runtime libraries, CA certificates, config |
| User | Root is acceptable | Non-root |
| Size | Irrelevant (never shipped) | As small as practical |
| Cache strategy | Manifests first, then source | Rarely changes, apart from the artifact |
Common stage layouts
Two stages Three stages (shared base) Parallel builders
---------- -------------------------- -----------------
build -> runtime base ----+--> dev frontend-build --+
+--> build -> runtime backend-build --+--> runtime
- A base stage holds what every other stage shares (system packages, a user,
WORKDIR). Later stages useFROM base AS .... - A deps stage installs dependencies only, so they are cached independently of source changes.
- Independent builder stages (for example a frontend and a backend) are built in parallel by BuildKit.
Example: shared base, separate build and runtime
# syntax=docker/dockerfile:1
# Shared foundation
FROM python:3.13-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Builder: compilers and headers needed for native wheels
FROM base AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
# Runtime: only the shared library needed at runtime, plus the venv and code
FROM base AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 app
COPY --from=build /opt/venv /opt/venv
COPY . .
ENV PATH="/opt/venv/bin:$PATH"
USER 10001
CMD ["python", "app.py"]
Key takeaways
- The builder stage is optimized for building and caching; the runtime stage for size and security.
- Use a shared base stage to avoid duplicating common setup.
- Separate dependency installation into its own stage or step to maximize cache hits.
- Independent builder stages run in parallel with BuildKit.
- Runtime stages often need runtime libraries (e.g.
libpq5) but not their development packages (libpq-dev).
🧪 Practice
- (Beginner) Build the example's
buildandruntimestages separately with--targetand compare their sizes.- (Intermediate) Remove
libpq5from the runtime stage and run the application (using a library such aspsycopg2that needs it). Read the error, explain it, and fix it.- Interview question: "How do you decide what belongs in the runtime stage?" Hint: think about what the process needs at startup and while serving (shared libraries, certificates, timezone data, config) versus what is only needed to produce the artifact.
Copying Artifacts Between Stages
Why copying is the key operation. Stages are isolated filesystems; the only way to move something from the builder into the runtime image is to copy it explicitly. What you copy, and how precisely, decides how small and clean the final image is. Copying a whole directory "to be safe" can bring the source code, caches, and secrets right back into the image you were trying to keep minimal.
COPY --from sources
| Form | Example | Notes |
|---|---|---|
| Named stage | COPY --from=build /out/app /app | Preferred: readable and stable |
| Stage index | COPY --from=0 /out/app /app | Fragile: breaks when stages are reordered |
| External image | COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/ | Pulls the image if needed; pin it like a base image |
| Named build context | COPY --from=assets . /srv/static | Context passed with --build-context assets=... |
Useful flags
--chown=uid:gidand--chmod=755set ownership and permissions while copying, avoiding an extra layer.--linkcopies the files into an independent layer that does not depend on the layers beneath it. If the base image changes, the copied layer can be reused instead of being recreated, which speeds up rebuilds and "rebases".
What runtime stages commonly need besides the binary
From the builder (or an SDK image) Why
----------------------------------------- ----------------------------------------
/out/app the application itself
/etc/ssl/certs/ca-certificates.crt outbound HTTPS (TLS verification)
/usr/share/zoneinfo (or embedded tz data) time zone conversions
/etc/passwd, /etc/group (minimal entries) a named non-root user in scratch images
static assets, templates, migrations files the app reads at runtime
Example
# syntax=docker/dockerfile:1
FROM node:22-slim AS frontend
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ .
RUN npm run build
# Output in /web/dist
FROM golang:1.23 AS backend
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# -tags timetzdata embeds the time zone database in the binary (no /usr/share/zoneinfo needed)
RUN CGO_ENABLED=0 go build -tags timetzdata -o /out/server ./cmd/server
FROM scratch
# Only the exact files needed, each with explicit ownership
COPY --from=backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=backend --chown=10001:10001 /out/server /server
COPY --from=frontend --chown=10001:10001 /web/dist /static
USER 10001:10001
ENTRYPOINT ["/server", "--static-dir=/static"]
docker build -t fullstack .
# Verify what reached the final image (scratch has no shell: export and list instead)
docker create --name probe fullstack
docker export probe | tar -t | head -20
docker rm probe
Key takeaways
COPY --from=<stage|image|context>is the only bridge between stages.- Copy exact paths, not whole working directories, so source, caches, and secrets stay behind.
- Name stages instead of using numeric indexes.
- Remember non-obvious runtime needs: CA certificates, time zone data, user entries, assets.
--chown,--chmod, and--linkmake copies cleaner and rebuilds faster.
🧪 Practice
- (Beginner) Copy
/etc/os-releasefromdebian:12-sliminto analpine:3.20image withCOPY --from, then print both release files.- (Intermediate) Remove the CA certificates line from the example and make the server perform an HTTPS request at startup. Observe the error, explain it, and restore the line.
- Interview question: "A multi-stage image is unexpectedly large. How do you find out what was copied into it, and what typical mistakes cause this?" Hint: think about
docker image history, layer inspection tools, and broadCOPY --from=build /app /applines.
Targeting Specific Stages
Why target stages. One Dockerfile can describe more than one useful image: a development image with debugging tools and hot reload, a test image that runs the test suite, and a slim production image. --target builds the Dockerfile only up to a named stage, so the same file serves every environment without duplication, and CI can run tests in exactly the same environment it builds for production.
How it works. docker build --target <stage> treats the named stage as the final one. BuildKit only builds the stages that this target depends on.
+--> dev (docker build --target dev)
|
base --> deps -+--> test (docker build --target test) runs the test suite
|
+--> build --> prod (default: last stage)
Example
# syntax=docker/dockerfile:1
FROM node:22-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./
# All dependencies (including devDependencies)
FROM base AS deps
RUN npm ci
# Development: hot reload, source mounted at runtime
FROM deps AS dev
ENV NODE_ENV=development
CMD ["npm", "run", "dev"]
# Test: the build fails if any test fails
FROM deps AS test
COPY . .
RUN npm run lint && npm test
# Build the production bundle
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
# Production (last stage = default target)
FROM node:22-slim AS prod
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
docker build --target dev -t web:dev . # for docker compose / local work
docker build --target test -t web:test . # CI: non-zero exit if tests fail
docker build -t web:1.0 . # prod: the last stage
# Export only test reports from a stage, without creating an image
# (requires a stage that copies reports to its root, e.g. FROM scratch AS reports)
docker build --target reports -o type=local,dest=./reports .
In Compose, the same idea is expressed with build.target: dev (see build vs image).
Key takeaways
--target <stage>stops the build at a named stage and makes it the output.- Only stages the target depends on are built (with BuildKit).
- A single Dockerfile can define dev, test, and prod images that share cached stages.
- A test stage that runs the suite in a
RUNstep makes the build itself fail on test failures. - Without
--target, the last stage is built, so put the production stage last.
🧪 Practice
- (Beginner) Build the
devandprodtargets of the example and compare their sizes and the presence of devDependencies.- (Intermediate) Add a
FROM scratch AS reportsstage that copies a coverage report from theteststage, and export it to your machine with-o type=local.- Interview question: "Your production stage is not the last one in the Dockerfile, and someone runs
docker build .without--target. What happens, and how would you prevent mistakes?" Hint: think about the default target and conventions for stage ordering.
Language-Specific Patterns
Why patterns differ by language. Every ecosystem produces a different kind of artifact. Go and Rust can compile a single static binary; Java produces JARs that need a JVM; Node and Python ship interpreted source plus a dependency tree. The right multi-stage layout depends on what has to be carried from the builder to the runtime and what the runtime needs to execute it.
Overview
| Language | Builder base | Artifact to copy | Runtime base | Notes |
|---|---|---|---|---|
| Go | golang | One static binary | scratch, distroless/static | CGO_ENABLED=0; add CA certificates if needed |
| Rust | rust | One binary | distroless/cc, debian-slim; scratch with musl target | Build dependencies first with a dummy main.rs or a cache mount |
| Java | maven, gradle, eclipse-temurin:*-jdk | JAR (or exploded layers) | eclipse-temurin:*-jre, distroless/java | Optional jlink for a custom minimal JRE |
| Node.js | node | node_modules (prod only) + built output | node:*-slim, distroless/nodejs | npm ci, then prune devDependencies |
| Python | python | Virtual environment + source | python:*-slim | Build native wheels in the builder; copy the venv |
| Static frontend | node | dist/ folder | nginx:alpine, caddy | No Node.js in production at all |
Go
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Static binary, stripped of debug info
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
Java (Maven)
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
# Resolve dependencies first: cached until pom.xml changes
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -q package -DskipTests
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 app
WORKDIR /app
COPY --from=build /src/target/app.jar app.jar
USER 10001
# Respect the container's memory limit instead of the host's RAM
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
Python
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.13-slim
# Same base and Python version as the builder, so the venv paths and ABI match
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY . .
RUN useradd --system --uid 10001 app
USER 10001
CMD ["python", "-m", "gunicorn", "app:app", "--bind", "0.0.0.0:8000"]
Static frontend (React, Vue, Angular, ...)
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /web
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /web/dist /usr/share/nginx/html
# nginx.conf with SPA fallback (try_files $uri /index.html)
COPY nginx.conf /etc/nginx/conf.d/default.conf
Cross-language rules of thumb
- Copy dependency manifests and install dependencies before copying source code.
- Keep builder and runtime on compatible bases (same libc, same language version) unless the artifact is fully static.
- Install only runtime packages in the final stage; development headers stay in the builder.
- For faster dependency downloads across builds, use BuildKit cache mounts (see Cache Mounts).
Key takeaways
- The artifact type decides the pattern: static binary, JAR, venv,
node_modules+ bundle, or static files. - Compiled languages can often run on
scratchor distroless; interpreted ones need a slim runtime. - Match libc and language versions between builder and runtime stages.
- Frontend builds should end in a web server image, not a Node.js image.
🧪 Practice
- (Beginner) Containerize a small static website built with a JavaScript framework using the frontend pattern, and compare the final size with the
node:22-slimimage.- (Intermediate) Build the Python pattern with a dependency that has native code (e.g.
psycopg2orlxml). Then switch only the runtime stage topython:3.13-alpineand explain the failure.- Interview question: "How would you containerize a Spring Boot application so that rebuilds after a small code change are fast and the image is small?" Hint: think about caching Maven/Gradle dependencies, JDK versus JRE, layered JARs or
jlink, and container-aware JVM memory flags.
<a id="55-dockerfile-best-practices"></a>
5.5 Dockerfile Best Practices
This section collects the practices that make images fast to build, small to ship, secure to run, and reproducible over time.
Layer Ordering
Why ordering matters. Because a cache miss invalidates every following step (see Cache Invalidation), the position of each instruction decides how much work a typical rebuild does. The same instructions in a different order can mean a 5-second rebuild or a 5-minute one, and a 2 MB or 200 MB download for every deployment that has to pull the image.
Analogy. Think of packing a suitcase you repack every day. Things you never change (shoes, toiletries) go at the bottom; things you swap daily (today's shirt) go on top. If you put the daily shirt at the bottom, you have to unpack everything each morning.
The principle: from stable to volatile
Change frequency Instruction examples
---------------- --------------------------------------------------------
Almost never FROM, system packages (apt/apk), users, WORKDIR
Rarely language runtime config, tool downloads
Sometimes dependency manifests + install (package.json, go.mod,
requirements.txt, pom.xml)
Every commit COPY of source code, build of the application
Metadata (cheap) ENV, EXPOSE, USER, CMD (placement matters less, but
values that change per build, like a version label,
belong at the end)
Bad vs good
# BAD: any source change re-runs npm ci and re-downloads all dependencies
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
CMD ["node", "server.js"]
# GOOD: stable steps first, dependencies cached until the manifests change
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]
Impact on deployments. Ordering also affects pulls: when only the top layer changes, servers that already have the previous version download only that layer.
| Change | Bad ordering: rebuilt / pulled | Good ordering: rebuilt / pulled |
|---|---|---|
Edit server.js | COPY . . + npm ci + apt step (~150 MB) | COPY . . only (~200 KB) |
| Add a dependency | Same as above | npm ci + COPY . . |
| Base image update | Everything | Everything |
Key takeaways
- Order instructions from least to most frequently changing.
- Copy dependency manifests and install dependencies before copying the source code.
- Good ordering speeds up both builds (cache hits) and deployments (fewer layers to pull).
- Place values that change on every build (version labels, build args) as late as possible.
🧪 Practice
- (Beginner) Build both versions of the example, edit
server.js, rebuild, and compare the time and theCACHEDlines.- (Intermediate) Push both versions to a local registry (
registry:2), change one source line, push again, and compare how many bytes each push uploads.- Interview question: "Given a Dockerfile that copies the whole repository on its second line, how would you restructure it for a Python project using
requirements.txt?" Hint: separate the manifest copy and install from the source copy, and think about what else changes rarely.
Minimizing Layers
Why it matters, and why not too much. Each RUN, COPY, and ADD creates a layer, and files written in one layer cannot be truly removed by a later one: a later delete only adds a whiteout on top while the bytes remain in the image. So the step that downloads, uses, and cleans up temporary files must be a single instruction. That is the real reason to merge commands. Reducing the layer count for its own sake brings little benefit, and merging unrelated steps hurts caching and readability.
The deleted-file trap
# Three layers: the 300 MB archive is still inside layer 1
RUN curl -fsSLO https://example.com/sdk.tar.gz
RUN tar -xzf sdk.tar.gz -C /opt && /opt/sdk/install.sh
RUN rm sdk.tar.gz
layer 3: whiteout for sdk.tar.gz 0 B (hides the file)
layer 2: /opt/sdk + installed files 150 MB
layer 1: sdk.tar.gz 300 MB <- still shipped and pulled
----------------------------------------------
total 450 MB
# One layer: the archive never reaches a committed layer
RUN set -eux; \
curl -fsSLO https://example.com/sdk.tar.gz; \
tar -xzf sdk.tar.gz -C /opt; \
/opt/sdk/install.sh; \
rm -rf sdk.tar.gz /opt/sdk/docs
Guidelines
| Do | Avoid |
|---|---|
Merge a download/install/cleanup sequence into one RUN | Merging unrelated steps that change at different rates |
Combine package installs into one apt-get install with a sorted list, one package per line | A separate RUN apt-get install per package |
| Use multi-stage builds to drop whole layers of build tooling | Fighting layer counts in a single stage with very long RUN lines |
| Use heredocs for readable multi-line scripts (see Heredocs in Dockerfiles) | Unreadable 30-line chains of && \ |
| Remember that metadata instructions do not create layers | Squashing everything into one layer, which defeats layer sharing |
Example: readable and minimal
# syntax=docker/dockerfile:1
FROM debian:12-slim
# One RUN, sorted package list, cleanup in the same layer
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
jq \
&& rm -rf /var/lib/apt/lists/*
# Heredoc form (Dockerfile syntax 1.4+): still one layer
RUN <<EOF
set -eux
curl -fsSLO https://example.com/sdk.tar.gz
tar -xzf sdk.tar.gz -C /opt
rm sdk.tar.gz
EOF
docker image history --format 'table {{.CreatedBy}}\t{{.Size}}' myimage
Key takeaways
- Deleting files in a later layer does not shrink the image; clean up in the same
RUNthat created them. - Merge steps that belong together (download, install, clean); keep unrelated steps separate for caching.
- Sort multi-line package lists for readability and clean diffs.
- Multi-stage builds remove build-time layers more effectively than chaining commands.
- Fewer layers is a means, not a goal.
🧪 Practice
- (Beginner) Reproduce the three-layer SDK example with any large file (e.g.
dd100 MB), then the single-layer version. Compare sizes withdocker image lsanddocker image history.- (Intermediate) Take a Dockerfile with ten
RUNlines and decide which ones to merge and which to keep separate. Justify each decision in terms of caching and size.- Interview question: "Is it always better to have fewer layers?" Hint: weigh removed-but-shipped files against cache granularity, parallel layer downloads, and sharing layers between images.
Choosing Minimal Base Images
Why minimal. Every package in the base image is something you ship, pull, scan, and patch, even if your application never uses it. A smaller base means faster pulls and startups, fewer CVEs in scanner reports, and fewer tools available to an attacker. But "minimal" has costs too: missing libraries, a different C library, or no shell for debugging. The goal is the smallest base that runs your application reliably. The families of base images are introduced in Base Images; this topic is about choosing between them.
Variants of official images. Most official language images come in several flavors:
| Tag suffix | Example | Based on | Typical use |
|---|---|---|---|
(none) / -bookworm | python:3.13 | Full Debian with build tools | Builder stages |
-slim / -slim-bookworm | python:3.13-slim | Debian slim, minimal packages | Default runtime choice |
-alpine | python:3.13-alpine | Alpine Linux (musl) | Very small runtimes, when compatible |
-jre vs -jdk | eclipse-temurin:21-jre | Runtime only vs full SDK | Java runtime vs build |
Alpine and musl: what to watch for. Alpine is small mainly because it uses musl libc and BusyBox instead of glibc and GNU tools. This is fine for many workloads, but:
- Prebuilt binaries and Python wheels built for glibc (
manylinux) do not work; pip may have to compile packages from source, which makes builds slower and the builder stage bigger. - DNS resolution, locale handling, and memory allocator performance differ from glibc and can show up as subtle behavior or performance changes.
- BusyBox tools accept fewer options than their GNU counterparts, so scripts may break.
Decision guide
Is the artifact a static binary (Go with CGO_ENABLED=0, Rust musl)?
yes --> scratch or distroless/static
no
|
Does it need only a language runtime + glibc?
yes --> distroless (java, nodejs, cc, python) or <lang>:<ver>-slim
no
|
Does it need extra system packages or a shell at runtime?
yes --> debian-slim / ubuntu based image, or alpine if every dependency supports musl
Example: comparing candidates
for img in python:3.13 python:3.13-slim python:3.13-alpine; do
docker pull -q "$img" >/dev/null
printf '%-22s %s\n' "$img" "$(docker image inspect -f '{{.Size}}' "$img" | numfmt --to=iec)"
done
# python:3.13 ~1.0G
# python:3.13-slim ~120M
# python:3.13-alpine ~45M
# Package count, a rough proxy for attack surface
docker run --rm python:3.13-slim sh -c 'dpkg -l | grep -c ^ii'
docker run --rm python:3.13 sh -c 'dpkg -l | grep -c ^ii'
# syntax=docker/dockerfile:1
# Full image for building wheels, slim image for running them
FROM python:3.13 AS build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.13-slim
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
Key takeaways
- Choose the smallest base that runs your application reliably, not the smallest that exists.
-slimvariants are a safe default for runtimes; full images are best for builder stages.- Alpine is small but uses musl and BusyBox; check binary, wheel, and script compatibility.
- Static binaries can use
scratchor distroless; others need at least the runtime and libc. - Fewer packages means fewer vulnerabilities to triage and patch.
🧪 Practice
- (Beginner) Compare the sizes and package counts of
node:22,node:22-slim, andnode:22-alpine.- (Intermediate) Install a Python package with native extensions (e.g.
pandas) onpython:3.13-slimand onpython:3.13-alpine. Compare build times, final sizes, and whether prebuilt wheels were used.- Interview question: "Your team switched to Alpine to shrink images, and now some requests are slower and one library crashes. What could be going on?" Hint: think about musl versus glibc, DNS resolver behavior, memory allocators, and binaries compiled for glibc.
Distroless and Scratch Images
Why go further than slim. Even a slim distro contains a shell, a package manager, and coreutils. Your application never calls them, but an attacker who gains code execution can use them to download tools and explore. Distroless images contain only the application's runtime dependencies (libc, CA certificates, time zone data, a passwd file, and the language runtime if needed), with no shell or package manager. scratch goes all the way: an empty filesystem in which you provide every file yourself.
Analogy. A slim image is a furnished studio apartment. Distroless is a hotel room: bed, light, and a lock, nothing to cook with. Scratch is an empty room into which you carry exactly the objects you need.
Comparison
| Aspect | debian:12-slim | Distroless (gcr.io/distroless/*) | scratch |
|---|---|---|---|
| Shell / package manager | Yes / Yes | No / No | No / No |
| libc | glibc | glibc (base, cc), none in static | None (static binaries only) |
CA certs, tzdata, /etc/passwd | Installable | Included | You copy them in |
| Non-root user | You create it | Built in (:nonroot tag, UID 65532) | You define it (numeric USER) |
| Typical size | ~75 MB | ~2 MB (static) to ~200 MB (java) | Size of your files |
docker exec sh | Works | Fails (use the :debug tag or docker debug) | Fails |
Shell-form CMD/RUN | Works | Fails: exec form only | Fails: exec form only |
Distroless image families
| Image | For |
|---|---|
gcr.io/distroless/static-debian12 | Static binaries (Go, Rust musl) |
gcr.io/distroless/base-debian12 | Dynamically linked binaries needing glibc |
gcr.io/distroless/cc-debian12 | Like base, plus libgcc/libstdc++ (Rust, C++) |
gcr.io/distroless/java21-debian12 | JVM applications |
gcr.io/distroless/nodejs22-debian12 | Node.js applications |
Similar minimal, frequently rebuilt images are offered by other vendors, such as Chainguard Images and Docker Hardened Images.
Example: scratch, done properly
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS build
RUN apk add --no-cache ca-certificates \
&& echo 'app:x:10001:10001:app:/:/sbin/nologin' > /etc/passwd.min \
&& echo 'app:x:10001:' > /etc/group.min
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -tags timetzdata -ldflags="-s -w" -o /out/app ./cmd/app
FROM scratch
# TLS roots, minimal user database, the binary: nothing else
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /etc/passwd.min /etc/passwd
COPY --from=build /etc/group.min /etc/group
COPY --from=build /out/app /app
USER 10001:10001
# Exec form is mandatory: there is no /bin/sh
ENTRYPOINT ["/app"]
Debugging shell-less containers
# Distroless debug variant includes a BusyBox shell
docker run --rm -it --entrypoint sh gcr.io/distroless/base-debian12:debug
# Attach a toolbox to a running shell-less container (Docker Desktop feature)
docker debug mycontainer
# Generic approach: a tools container sharing the target's PID and network namespaces
docker run --rm -it --pid container:mycontainer --network container:mycontainer busybox sh
See docker debug for more details.
Key takeaways
- Distroless images contain the runtime dependencies only: no shell, no package manager.
scratchis empty; you must add CA certificates, time zone data, and user entries yourself if needed.- Both require exec-form
CMD/ENTRYPOINTand aHEALTHCHECKthat does not depend on a shell. - Use
:nonrootdistroless tags or a numericUSERin scratch images. - Plan debugging in advance:
:debugtags,docker debug, or namespace-sharing sidecar containers.
🧪 Practice
- (Beginner) Run
docker run --rm -it gcr.io/distroless/static-debian12 shand explain the error. Then try the:debugtag.- (Intermediate) Build the scratch example without the CA certificates and make the app call an HTTPS endpoint. Then remove the
/etc/passwdcopy, changeUSERtoapp, and explain the second failure.- Interview question: "What are the trade-offs of distroless images, and how would you troubleshoot a production issue inside one?" Hint: weigh attack surface and CVE count against debuggability, and think about ephemeral debug containers and namespace sharing.
Pinning Versions
Why pin. A Dockerfile that says FROM node and RUN apt-get install curl describes a moving target: the same file produces a different image every week. That breaks reproducibility (a bug cannot be reproduced from last month's build), introduces surprise breakage (a new major version arrives silently), and weakens supply-chain security (a compromised or re-pushed tag is accepted automatically). Pinning makes inputs explicit, so changes become deliberate, reviewable updates.
Analogy. Pinning is like a lockfile for your operating system. Without it, "install the dependencies" means "install whatever is newest today".
Levels of pinning
| What | Loose | Better | Strict |
|---|---|---|---|
| Base image | FROM node (latest) | FROM node:22.11-slim | FROM node:22.11-slim@sha256:<digest> |
| OS packages | apt-get install curl | curl=7.88.* | Exact version, or a snapshot repository |
| Language dependencies | pip install flask | flask==3.0.3 in requirements.txt | Lockfile with hashes (pip-compile --generate-hashes, package-lock.json + npm ci, go.sum) |
| Downloaded tools | curl .../latest/tool | Versioned URL | Versioned URL + checksum (ADD --checksum, sha256sum -c) |
| Dockerfile frontend | none | # syntax=docker/dockerfile:1 | # syntax=docker/dockerfile:1.10 (or a digest) |
The trade-off: reproducibility versus freshness. A pinned digest never receives security fixes on its own. Pinning without an update process just freezes vulnerabilities in place. The solution is to pin and automate updates:
pin exact versions/digests --> bot opens PR when upstream changes --> CI builds & tests
(reproducible) (Renovate, Dependabot) (safe to merge)
^ |
+------------------------------ merge ---------------------------------+
Example
# syntax=docker/dockerfile:1
# Tag documents intent, digest guarantees content (Renovate/Dependabot can update both)
FROM python:3.13.1-slim-bookworm@sha256:<digest>
# Exact OS package versions (look them up with: apt-cache policy <pkg>)
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpq5=15.* \
&& rm -rf /var/lib/apt/lists/*
# Fully locked Python dependencies with hashes
COPY requirements.lock .
RUN pip install --no-cache-dir --require-hashes -r requirements.lock
# Pinned tool download with verification
ARG HADOLINT_VERSION=2.12.0
ARG HADOLINT_SHA256=<sha256>
RUN curl -fsSL -o /usr/local/bin/hadolint \
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-x86_64" \
&& echo "${HADOLINT_SHA256} /usr/local/bin/hadolint" | sha256sum -c - \
&& chmod +x /usr/local/bin/hadolint
# Find the digest currently behind a tag (the "Digest:" line of the index)
docker buildx imagetools inspect python:3.13.1-slim-bookworm | grep -m1 Digest
# or after pulling
docker image inspect python:3.13.1-slim-bookworm --format '{{index .RepoDigests 0}}'
Key takeaways
- Unpinned inputs make builds non-reproducible and can introduce silent breakage.
- Pin base images to a specific tag, and to a digest for strict reproducibility.
- Use lockfiles with hashes for language dependencies and checksums for downloaded tools.
- Pinning must be paired with automated update PRs, or you freeze vulnerabilities in place.
- Rebuild regularly even without code changes, so pinned bases still receive patches through updates.
🧪 Practice
- (Beginner) Find the current digest of
alpine:3.20and rewrite aFROMline to pin it. Verify thatdocker builduses that digest in its output.- (Intermediate) Generate a hashed lockfile for a small Python project with
pip-compile --generate-hashes, install it with--require-hashes, then modify one hash and observe the failure.- Interview question: "Pinning by digest improves reproducibility but seems to conflict with getting security updates. How do you reconcile the two?" Hint: think about automated dependency update tools, scheduled rebuilds, and vulnerability scanning in CI.
Reducing Image Size
Why size matters. Image size affects every place the image travels: CI upload time, registry storage costs, pull time when scaling out or recovering from a node failure, cold starts in serverless platforms, and the number of packages a scanner reports. Shrinking an image is usually a combination of the techniques from this chapter, applied in order of impact.
Techniques, roughly by impact
| Technique | Typical saving | Where it is covered |
|---|---|---|
| Multi-stage build: ship artifacts, not toolchains | 50-95% | 5.4 Multi-Stage Builds |
| Smaller base image (slim, distroless, scratch) | 50-90% | Choosing Minimal Base Images |
| Exclude unneeded files from the context | Varies; can be huge with COPY . . | .dockerignore |
Install only what is needed (--no-install-recommends, --omit=dev) | 10-50% | below |
Clean caches in the same layer (apt lists, pip/npm caches) | 5-30% | Minimizing Layers |
Avoid duplicated data (COPY then chown -R) | Up to 2x per affected file | COPY vs ADD |
Strip binaries / remove debug symbols (-ldflags="-s -w", strip) | 20-30% of the binary | below |
Package manager flags that matter
| Ecosystem | Smaller install |
|---|---|
| apt | apt-get install -y --no-install-recommends ... && rm -rf /var/lib/apt/lists/* |
| apk | apk add --no-cache ... (no index stored in the layer) |
| pip | pip install --no-cache-dir ... |
| npm | npm ci --omit=dev && npm cache clean --force |
| Go | go build -trimpath -ldflags="-s -w" |
Finding what takes space
# Layer by layer: which instruction added how much
docker image history --format 'table {{.Size}}\t{{.CreatedBy}}' myapp:1.0
# Interactive layer explorer showing files per layer and wasted space
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive myapp:1.0
# Largest directories inside the image (needs a shell in the image)
docker run --rm --entrypoint sh myapp:1.0 -c 'du -xh / 2>/dev/null | sort -h | tail -20'
Worked example: from 1.1 GB to 60 MB
# BEFORE (~1.1 GB): full image, build tools shipped, caches kept, everything copied
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]
# syntax=docker/dockerfile:1
# AFTER (~60-150 MB depending on dependencies)
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build \
&& npm prune --omit=dev \
&& npm cache clean --force
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
# The distroless nodejs entrypoint is "node", so CMD is just the script
CMD ["dist/server.js"]
Together with a .dockerignore that excludes node_modules, .git, and test data, this applies almost every technique in the table.
Key takeaways
- Size affects build, push, pull, storage, startup, and vulnerability exposure.
- Biggest wins first: multi-stage builds and a smaller base image.
- Then:
.dockerignore, no recommended/dev packages, caches removed in the same layer, stripped binaries. - Measure with
docker image historyanddiveinstead of guessing. - Smaller is better only as long as the image remains correct and operable.
🧪 Practice
- (Beginner) Run
docker image historyon one of your images and identify the three largest layers and the instructions that created them.- (Intermediate) Take the "before" Node.js Dockerfile (or one of your own) and reduce it step by step, recording the size after each change in a table. Which change had the largest effect?
- Interview question: "An image has grown to 2 GB and deployments are slow. Walk me through how you would analyze and reduce it." Hint: start with measurement (history, dive), then look at the base image, multi-stage separation, context contents, package manager options, and duplicated files across layers.
<a id="6-data-persistence-and-storage"></a>