Docker & ContainersV Β· Dockerfiles & BuildKitInstructions
Dockerfile instructions β every operator-relevant instruction
What you'll learn
- Explain the operational implications of every common Dockerfile instruction
- Choose between COPY and ADD
- Configure USER, ENTRYPOINT, CMD, HEALTHCHECK for production
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-09
Every Dockerfile instruction produces a layer. Every layer is immutable once built. Choosing instructions carefully is the difference between a 200 MB image that starts in 200 ms and a 2 GB image that takes 4 seconds.
The instructions
FROM β base image
FROM ubuntu:24.04
FROM gcr.io/distroless/static-debian12:nonroot
FROM scratch
FROM ubuntuβ full base. ~77 MB.FROM debian:bookworm-slimβ smaller. ~25 MB.FROM alpineβ musl libc. ~6 MB. Compatibility quirks.FROM gcr.io/distroless/staticβ no shell, no package manager. ~2 MB.FROM scratchβ empty. For static binaries.
Choose the smallest base that runs your workload. The base image determines every vulnerability inherited; the smaller the base, the smaller the attack surface.
RUN β execute commands
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
Every RUN produces a layer. The layerβs filesystem state is what
matters, not the commands themselves. rm -rf in the same RUN
keeps the layer small.
Always combine apt-get update and apt-get install in a single
RUN. Otherwise the layer cache can hold a stale package index.
COPY vs ADD β the only really confused instruction
COPY ./app /app
ADD https://example.com/foo.tar.gz /tmp/
COPYis a literal copy from the build context. Predictable.ADDdoes the same but also:- Fetches remote URLs and Git repositories.
- Auto-extracts local
.tararchives.
The Docker best practice is to use COPY unless you specifically
need ADDβs features. ADDβs magic hides where files came from
and how they were extracted.
USER β the most important instruction for production
USER nginx
USER 1001
Without USER, the container runs as UID 0 (root). Combined with
the lesson on capabilities and user namespaces, you almost always
want a non-root user.
The Docker Official Images provide nobody (UID 65534). For your
own images, create a user:
RUN groupadd -r app && useradd -r -g app -u 1001 app
USER app
WORKDIR β set the working directory
WORKDIR /app
WORKDIR sets the working dir for subsequent instructions. It
creates the directory if it does not exist. Prefer this to RUN cd.
ENV β environment variables
ENV NODE_ENV=production
ENV persists in the image and is set for every container started
from it. Override at run time:
docker run -e NODE_ENV=staging myorg/myapp:1.0.0
Never put secrets in ENV. They are visible in
docker image inspect and in the registry.
ARG β build-time variables
ARG VERSION=1.0.0
RUN echo "Building version $VERSION" > /version.txt
ARG is only available during build. Not visible in docker inspect runtime config. Override:
docker build --build-arg VERSION=2.0.0 .
EXPOSE β documentation
EXPOSE 80 443
EXPOSE does not publish ports. It is documentation. The
runtime -p flag actually publishes. Some tools use EXPOSE for
network policy.
LABEL β image metadata
LABEL org.opencontainers.image.title="My App" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.source="https://github.com/myorg/myapp"
Use OCI annotation labels. They are visible in
docker image inspect and queryable via the registry API.
ENTRYPOINT vs CMD
ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]
ENTRYPOINTis the executable.CMDis the default arguments.
The runtime executes ENTRYPOINT CMD. Both are shown here in the
exec form β a JSON array β and that is not a style preference.
IMAGE=myorg/api:1.4.0
docker image inspect "$IMAGE" \
--format 'Entrypoint: {{json .Config.Entrypoint}}{{"\n"}}Cmd: {{json .Config.Cmd}}{{"\n"}}StopSignal: {{.Config.StopSignal}}'Entrypoint: null
Cmd: ["/bin/sh","-c","python3 /app/server.py"]
StopSignal:Illustrative output
A healthy image reads Cmd: ["python3","/app/server.py"]. Run this
against your own base images before you trust their shutdown
behaviour β it is a one-line check that catches an entire class of
data-loss-on-deploy.
To override the command at runtime:
docker run myorg/myapp:1.0.0 --help
# Runs: nginx --help
docker run --entrypoint sh myorg/myapp:1.0.0 replaces the
entrypoint entirely.
HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost/health || exit 1
Tests whether the container is healthy. The result is visible in
docker ps (STATUS column shows β(healthy)β) and reachable via the
Docker API.
The check command must exit 0 for healthy, 1 for unhealthy, 2 for reserved. Keep the command short and side-effect-free.
VOLUME β declared mount points
VOLUME /var/lib/mysql
Declares a mount point. Docker creates an anonymous volume if no named volume or bind mount is supplied at run time. Prefer named volumes for production.
USER β once more, because it matters
Instruction order is the buildβs performance budget
docker build -t cachetest . >/dev/null
touch README.md
docker build -t cachetest --progress=plain . 2>&1 | grep -E 'CACHED|^#[0-9]+ \[[0-9]+/'#5 [2/5] WORKDIR /app
#5 CACHED
#6 [3/5] COPY . .
#7 [4/5] RUN npm ci
#8 [5/5] COPY . .Illustrative output
WORKDIR is cached; COPY . . is not, and everything below it
rebuilds. Touching README.md β a file no dependency has ever
referenced β cost a full npm ci. After reordering, the same test
shows CACHED on both COPY package.json and RUN npm ci, and
only the final COPY . . re-runs. That difference between two
docker build runs is the verification; build wall-clock time on
its own is not, because it moves for unrelated reasons.
docker build --no-cache --progress=plain -t ctxtest . 2>&1 | grep -i 'transferring context'#2 [internal] load build context
#2 transferring context: 412.83MB 6.1s doneIllustrative output
412 MB for a Node project means node_modules and .git are in the
context. After a .dockerignore, the same line should read a few
hundred kilobytes β and the cache stops being invalidated by commits
that touch nothing the image contains.
A complete production Dockerfile
# syntax=docker/dockerfile:1.7
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/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build --chown=nonroot:nonroot /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Distroless, non-root, multi-stage. ~10 MB.
Knowledge check
Knowledge check Β· 6 questions
Q1. Which Dockerfile instruction sets the process UID for subsequent RUN/CMD/ENTRYPOINT steps?
Q2. `COPY` and `ADD` are functionally identical.
Q3. Which directive controls the container's working directory for subsequent steps?
Q4. A Dockerfile runs `COPY . .` and then `RUN npm ci`. Editing one source file causes a full dependency reinstall on every CI build. What is the fix?
Q5. `ADD https://example.com/tool.tar.gz /opt/` downloads the archive and extracts it into /opt/.
Q6. An image ships `CMD python3 /app/server.py` (shell form). What is the operational consequence?
Passing score: 75%. Answers are checked in this browser.