Skip to main content
RunBook Academy

Docker & ContainersV Β· Dockerfiles & BuildKitInstructions

Dockerfile instructions β€” every operator-relevant instruction

Foundation⏱ ~28 min

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

Not yet marked complete on this device.

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/
  • COPY is a literal copy from the build context. Predictable.
  • ADD does the same but also:
    • Fetches remote URLs and Git repositories.
    • Auto-extracts local .tar archives.

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;"]
  • ENTRYPOINT is the executable.
  • CMD is 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.

Read-only / Safecheck the form an image actually shipped
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

Read-only / Safefind the exact instruction that busts the cache
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.

Read-only / Safehow big is the build context you are actually sending?
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 done

Illustrative 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

  1. Q1. Which Dockerfile instruction sets the process UID for subsequent RUN/CMD/ENTRYPOINT steps?

  2. Q2. `COPY` and `ADD` are functionally identical.

  3. Q3. Which directive controls the container's working directory for subsequent steps?

  4. 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?

  5. Q5. `ADD https://example.com/tool.tar.gz /opt/` downloads the archive and extracts it into /opt/.

  6. 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.