Beginner · Docker · topic 16 of 53 · 35 min
Writing Dockerfiles: caching, multi-stage builds and small images
Explanation
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.
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"]The main instructions
| Instruction | What it does |
|---|---|
| FROM | The base image to start from. Always pin a version tag. |
| WORKDIR | Sets (and creates) the folder that later instructions and the app run in. |
| COPY | Copies files from the build context (the folder you build from) into the image. |
| ADD | Like COPY, but can also fetch URLs and auto-extracts local tar archives. Prefer COPY unless you need that. |
| RUN | Runs a command while BUILDING the image, such as installing packages. Each RUN makes a layer. |
| ENV | Sets an environment variable that stays in the image and in running containers. |
| ARG | A variable available only during the build (docker build --build-arg VERSION=2). |
| EXPOSE | Documents which port the app listens on. It does NOT publish the port; docker run -p does that. |
| USER | Which user later instructions and the container run as. Use a non-root user. |
| CMD | The default command when the container starts. Can be overridden on docker run. |
| ENTRYPOINT | The fixed program the container runs; CMD then supplies default arguments. |
| HEALTHCHECK | A 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.
ENTRYPOINT ["python", "report.py"]
CMD ["--month", "current"]
# docker run reports → python report.py --month current
# docker run reports --month 09 → python report.py --month 09HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1Layer 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.
# 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 . ..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.
.git
node_modules
__pycache__
*.log
.env
distMulti-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.
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"]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"]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
- 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
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.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Practice (0/3 done)
Which order of instructions makes the best use of the build cache for a Python app?
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.
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
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Running containers: ports, volumes, networks and limitsDocker · same module
- Docker Compose: multi-container appsDocker · same module
- Docker images for Go servicesGo Programming (backend and cloud) · CLI tools and Go in cloud and DevOps
- Docker: packaging your appBackend Development · Deployment and scaling
- Containers, images and registriesCloud Computing (AWS, Azure, Google Cloud) · Containers in the cloud
- Capstone project: from commit to monitored productionGitOps, platform engineering and DevOps careers
Questions people ask about this
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.
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.
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.
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.
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
- 3 more topics in “Docker” before its exam: Running containers: ports, volumes, networks and limits, Docker Compose: multi-container apps, Image and container security.
- Module 4 exam: Docker · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Kubernetes fundamentals. 53 topics left in the course before the final exam.