Skip to content

Beginner · Docker · topic 16 of 53 · 35 min

Writing Dockerfiles: caching, multi-stage builds and small images

Explanation

A Dockerfile is the recipe for an image: a base image, then steps that copy files and run commands. Docker caches each step, so ordering matters: copy dependency files and install them before copying the rest of your code. Multi-stage builds compile in one stage and copy only the result into a small final image. Run as a non-root USER, use exec-form CMD, and keep secrets out.

Why a Dockerfile?

You could start a container, install things by hand and save it, but nobody could repeat or review those steps. A Dockerfile is a plain text file in your repository that lists every step to build the image. It is reviewed in pull requests and rebuilt the same way by CI every time.

dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
A complete Dockerfile for a small Flask API. Build it with: docker build -t invoice-api:1.0.0 .

The main instructions

InstructionWhat it does
FROMThe base image to start from. Always pin a version tag.
WORKDIRSets (and creates) the folder that later instructions and the app run in.
COPYCopies files from the build context (the folder you build from) into the image.
ADDLike COPY, but can also fetch URLs and auto-extracts local tar archives. Prefer COPY unless you need that.
RUNRuns a command while BUILDING the image, such as installing packages. Each RUN makes a layer.
ENVSets an environment variable that stays in the image and in running containers.
ARGA variable available only during the build (docker build --build-arg VERSION=2).
EXPOSEDocuments which port the app listens on. It does NOT publish the port; docker run -p does that.
USERWhich user later instructions and the container run as. Use a non-root user.
CMDThe default command when the container starts. Can be overridden on docker run.
ENTRYPOINTThe fixed program the container runs; CMD then supplies default arguments.
HEALTHCHECKA command Docker runs regularly to test whether the app is healthy.

CMD and ENTRYPOINT, and the exec form

Write CMD and ENTRYPOINT in exec form, a JSON list such as CMD ["node", "server.js"]. The shell form, CMD node server.js, runs your app through /bin/sh -c, so the shell becomes the main process and your app may not receive the stop signal. Then docker stop waits 10 seconds and kills it, and your app never closes connections cleanly.

dockerfile
ENTRYPOINT ["python", "report.py"]
CMD ["--month", "current"]
# docker run reports            → python report.py --month current
# docker run reports --month 09 → python report.py --month 09
Arguments after the image name replace CMD but are passed to ENTRYPOINT.
dockerfile
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8000/health || exit 1
Make sure the tool you call exists in the image: slim images often have no curl.

Layer caching: order matters

Docker caches the result of each instruction. On the next build, it reuses cached layers until it reaches the first instruction whose input changed; that step and every step after it run again. Your code changes many times a day, but your dependency list changes rarely. So copy only the dependency files first, install, and then copy the rest.

dockerfile
# Slow: any code change re-runs npm ci
COPY . .
RUN npm ci

# Fast: npm ci re-runs only when package files change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
With the good order, a one-line code change rebuilds in seconds instead of minutes.

.dockerignore

The build context is everything in the folder you pass to docker build. A .dockerignore file lists what to leave out, using patterns like .gitignore. This keeps builds fast, keeps the cache stable and, most importantly, keeps secrets out of the image.

output
.git
node_modules
__pycache__
*.log
.env
dist

Multi-stage builds

Building needs compilers, dev dependencies and source code; running needs only the result. A multi-stage build uses several FROM lines. Early stages build; the final stage copies just what is needed with COPY --from. Everything else is left behind, so the final image is smaller and has fewer tools an attacker could use.

dockerfile
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The Go toolchain image is large; the final image holds only one static binary. The :nonroot tag already runs as an unprivileged user.
dockerfile
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
The same idea for Node.js: dev dependencies and source stay in the build stage.

Keeping images small

  • Start from a small base: -slim variants (Debian with fewer packages), -alpine (very small, but uses musl instead of glibc, which can break some compiled Python packages), or distroless images that contain only your app and its runtime, with no shell or package manager.
  • Install only what you need: apt-get install --no-install-recommends, and clean up in the SAME RUN: rm -rf /var/lib/apt/lists/*. Files deleted in a later layer still take space in the earlier one.
  • Use pip install --no-cache-dir and npm ci --omit=dev in the final image.

Common mistakes

Careful: Putting secrets in a Dockerfile, as ENV API_KEY=... or ARG with a password, or by copying .env into the image. Anyone who can pull the image can read them with docker history or docker inspect. Pass secrets at run time, or use build secrets (shown in the security lesson).
  • FROM python with no tag (that is :latest): your build changes when the base changes. Pin python:3.12-slim.
  • apt-get update in one RUN and apt-get install in a later RUN: the cached update can be stale. Combine them in one RUN.
  • No USER line: the app runs as root inside the container.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Writing Dockerfiles: caching, multi-stage builds and small images · 1/8

Dockerfile

A Dockerfile is a recipe for an image, kept in your repository and rebuilt the same way every time.

Prefer a video by a teacher? Find free videos on “dockerfile best practices multi stage build layer caching” on YouTube ↗

In real life: Cutting build time at an edtech company

An edtech company in Noida builds its Node.js API image about 60 times a day in CI. Their Dockerfile copied the whole project before npm ci, so every build reinstalled all packages. By copying package.json and package-lock.json first and switching to a multi-stage build with node:24-slim, most builds reused the dependency layer and the final image shrank a lot, so deploys to their servers also became faster because there was less to pull.

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
The three lines that made most builds reuse the dependency layer.

Practice (0/3 done)

Task 1 · Question

Which order of instructions makes the best use of the build cache for a Python app?

Task 2 · Code · Python

Write lint(text), a tiny Dockerfile linter. Return a sorted list of (line_number, code) tuples. Lines are numbered from 1. Skip blank lines and comments. A line ending in "\" continues on the next line; treat them as one instruction numbered by its first line. Instruction names may be in any case. Codes: - "pin-base": FROM image with no tag or the tag "latest". Skip "scratch", names of earlier stages (FROM x AS name), and images pinned with "@sha256:". Ignore options like --platform=... - "add-local": ADD whose first source does not start with http:// or https://. - "apt-cleanup": a RUN with "apt-get install" but no "rm -rf /var/lib/apt/lists". - "secret-env": an ENV whose variable name contains PASSWORD, SECRET, TOKEN or API_KEY (ENV KEY=value, possibly several, or ENV KEY value). - "no-user": (0, "no-user") if there is no USER instruction, or the last one is root or 0.

Task 3 · Practice

Write a multi-stage Dockerfile for a Python FastAPI service that lists its packages in requirements.txt and is started with: uvicorn main:app --host 0.0.0.0 --port 8000. Also write its .dockerignore. The final image should not contain build tools or your .env file, should run as a non-root user and should rebuild quickly when only code changes.

Quick quiz

Question 1

1.What does EXPOSE 8000 in a Dockerfile do?

Question 2

2.Why prefer CMD ["node", "server.js"] over CMD node server.js?

Question 3

3.What is the main benefit of a multi-stage build?

Question 4

4.One RUN installs packages and a LATER RUN deletes the apt cache. Why does the image not get smaller?

Question 5

5.When should you use ADD instead of COPY?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What does EXPOSE 8000 in a Dockerfile do?

Answer: Documents that the app listens on 8000; you still publish it with -p

EXPOSE is metadata. docker run -p 8000:8000 (or -P) actually publishes the port.

Why prefer CMD ["node", "server.js"] over CMD node server.js?

Answer: The exec form runs node directly, so it receives stop signals and can shut down cleanly

The shell form wraps the app in /bin/sh -c, which may not pass SIGTERM on to the app.

What is the main benefit of a multi-stage build?

Answer: Build tools and source stay in earlier stages, so the final image is smaller and safer

Only what you COPY --from into the last stage ends up in the final image.

One RUN installs packages and a LATER RUN deletes the apt cache. Why does the image not get smaller?

Answer: The files still exist in the earlier layer; deleting in a new layer only hides them

Each layer stores its own changes. Clean up in the same RUN that created the files.

When should you use ADD instead of COPY?

Answer: Only when you need its extra features, like auto-extracting a local tar archive or fetching a URL

COPY is simpler and predictable; ADD has extra behaviour that can surprise you.

Why copy go.mod and go.sum before the rest of the source?Related

Answer: So the go mod download layer is cached and reused when only code changes

Layer caching skips re-downloading dependencies.

From Docker images for Go services

Why copy package*.json and run npm ci BEFORE COPY . . in a Dockerfile?Related

Answer: So Docker can reuse the installed packages when only the code changes

Docker caches each step. If package files did not change, the install step is reused and builds are fast.

From Docker: packaging your app

You need a private token only while installing packages during the build. What is the right approach?Related

Answer: A BuildKit secret mount: RUN --mount=type=secret,...

Secret mounts exist only for that RUN step and are never stored in an image layer or the history.

From Image and container security

An image built on an ARM laptop fails on an x86 server with "exec format error". What is the fix?Related

Answer: Build for the right platform, for example with docker buildx build --platform linux/amd64

The binary inside was built for the wrong CPU architecture.

From Containers, images and registries

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 3 more topics in “Docker” before its exam: Running containers: ports, volumes, networks and limits, Docker Compose: multi-container apps, Image and container security.
  2. Module 4 exam: Docker · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Kubernetes fundamentals. 53 topics left in the course before the final exam.

← Previous: Containers, images and registries