Why images must be small
A large production image is slow to pull and deploy, uses more storage and carries extra packages, which means more potential vulnerabilities (a larger attack surface). Compilers, dev node_modules and source code do not belong in the image that runs in production.
Multi-stage build
FROM node:20-alpine AS build
WORKDIR /src
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /src/dist /usr/share/nginx/html
flowchart LR S1["build stage<br/>node + npm + source"] -- "COPY --from=build /src/dist" --> S2["final stage<br/>nginx + dist"] S2 --> IMG["Image app:1.0"]
Each FROM opens a stage. The resulting image is only the last stage (or the stage chosen with --target); earlier stages are left behind. --target build is handy for running tests in the build stage.
Build cache
Docker walks through each instruction: if the instruction and its inputs (files being COPYed) are unchanged, it reuses the old layer (cache hit). When a step is a cache miss, that step and every step after it are rebuilt.
- Put what changes least first:
COPY package*.json→RUN npm ci→COPY . .. Editing code then only misses fromCOPY . .. - Combine
apt-get update && apt-get install -y ...in oneRUN, so the package index is never stale from cache. --no-cacheignores the whole cache;--pullonly fetches the newest base image.
| Command | Meaning |
|---|---|
docker build --target build -t app:build . |
Build only up to the build stage |
docker build --no-cache -t app:1.0 . |
Rebuild every step without the cache |
docker build -f Dockerfile.prod -t app:prod . |
Use a Dockerfile with a non-default name |
docker image prune |
Remove dangling (untagged) images |
BuildKit and buildx
Modern Docker builds with BuildKit by default. It runs independent stages in parallel, skips stages the target does not need, and supports cache and secret mounts. docker buildx is the CLI for advanced builders, for example ones running in a container or on a remote machine.
Cache mounts
A cache mount keeps a package manager's download cache between builds without putting it in the image:
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
When package.json changes, the step reruns but reuses already downloaded packages, which makes rebuilds much faster.
Cache in CI
CI runners often start empty, so the local cache is lost. BuildKit can export and import cache through a registry:
docker buildx build --cache-from type=registry,ref=registry.example.com/team/app:cache \
--cache-to type=registry,ref=registry.example.com/team/app:cache,mode=max \
-t registry.example.com/team/app:1.0 --push .
GitHub Actions also has a dedicated type=gha cache backend.
Building for several CPUs
docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/team/app:1.0 --push .
This produces one tag with both variants, useful when developers use Apple Silicon laptops and servers run on x86 or Arm cloud VMs.
Checking what you build
docker build --check .reports Dockerfile problems, such as shell-formCMDwhere the JSON form is recommended or inconsistent instruction casing, without building.- Scanners such as Trivy (
trivy image app:1.0) or Docker Scout (docker scout cves app:1.0) list known vulnerabilities in OS packages and libraries. --sbom=trueand--provenance=trueattach a software bill of materials and build provenance to the image, which supply-chain tools can verify.
A small final stage, non-root user, pinned base versions, and regular rebuilds to pick up patched base images keep production images healthy over time.