Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Dockerfile best practices (cache, .dockerignore, multi-stage, non-root)

By updated

Improve this page

A Dockerfile decides what goes into the image you ship: your dependencies, but also whatever you forgot to remove, a compiler, a .git folder, a token. This guide gives the best practices that make an image faster to build, lighter and safer, for a developer or a DevOps engineer who already writes Dockerfiles. Every rule is measured with Docker 29.1.3: a build context that drops from 70 MB to 89 bytes, a Go image that falls from 261 MB to 11 MB, a --mount that works without the # syntax line said to be mandatory.

What you will learn

  • Order the instructions so that the cache helps on every build
  • Shrink the build context with a .dockerignore
  • Separate build and runtime with a multi-stage build
  • Run as non-root, and diagnose permission errors
  • Build without leaving a secret in the image, and pin the base image

Principle #1: Maximise the cache with instruction order

Docker builds an image layer by layer, one layer per instruction. When the input of an instruction changes, that layer and every following one is rebuilt. The order of the instructions therefore decides what each code change forces Docker to rebuild.

The rule: what changes rarely at the top, what changes often at the bottom. Dependencies change rarely, the code with every commit.

Effect of a change to src/index.js depending on instruction order: in the wrong order, COPY . . changes and RUN npm ci runs again; in the right order, the dependency files are copied first, RUN npm ci stays cached, and only the COPY . . layer is rebuilt.Right orderWrong orderCOPY . .RUN npm ci: replayedCOPY package.jsonpackage-lock.jsonRUN npm ci: cachedCOPY . .: onlylayer rebuiltChange tosrc/index.js
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %
# ❌ Wrong order: the whole project is copied before the install
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "src/index.js"]
# Right order: dependency files first, code afterwards
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "src/index.js"]

Measured on a small project that depends only on express, changing only src/index.js between two builds: with the wrong order, npm ci runs again and the build takes 1.9 s; with the right order, npm ci comes from the cache (CACHED in the output) and the build takes 0.5 s. The gap grows with the number of dependencies: on a real project, it is the difference between a few seconds and several minutes on every commit.

What changes rarelyWhat changes often
Base image (FROM)Source code
Installation of system toolsConfiguration files
Dependencies (package.json, go.mod)Static assets

Principle #2: Use .dockerignore

The build context is the folder that Docker sends to the builder before the first instruction. Anything it contains can end up in the image through a COPY . ., including a .git folder, a local node_modules or a .env. The .dockerignore file excludes these paths at send time: the build is faster, and a local secret can no longer be copied by mistake.

.dockerignore
# Dependencies (reinstalled in the image)
node_modules/
vendor/
__pycache__/
*.pyc
# Git and IDE
.git/
.vscode/
.idea/
# Local build files
dist/
build/
*.log
# Secrets and local configuration
.env
.env.*
*.pem
*.key
secrets/
.npmrc
.pypirc
kubeconfig

With BuildKit, the default builder, the detailed output shows the size of the context on a transferring context line. Measured on a folder that contains 48 MB of node_modules and 20 MB of .git:

Terminal window
docker build --progress=plain . 2>&1 | grep 'transferring context'
# without .dockerignore: transferring context: 70.02MB 0.2s done
# with .dockerignore: transferring context: 89B done

The .git folder deserves particular attention: on top of its weight, it holds the whole history, including a secret removed from the code long ago. The old Sending build context to Docker daemon line only appears with the legacy builder, which you no longer meet by default.

Principle #3: Multi-stage builds for lighter images

A multi-stage build uses several FROM lines in the same Dockerfile. The first stage holds the build tooling (compiler, development dependencies), the last one only holds what is needed to run. The gain is not only disk space: a compiler, a package manager or sources missing from the production image are as many tools an attacker will not find.

Go multi-stage build: in the build stage on golang:1.25-alpine, the sources, the dependencies and the compiler produce the /app binary; COPY --from=builder copies only this binary into the final alpine:3.22 stage, run as USER nobody; the final image weighs 10.8 MB instead of 261.4 MB.Final stage: alpine:3.22Build stage: golang:1.25-alpineCOPY --from=builderSources anddependenciesGo compilerBinary /appBinary /appUSER nobodyFinal image:10.8 MB instead of 261.4 MB
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Go example: from 261 MB to 11 MB

This example compiles a Go program in a first stage, then copies only the binary into a minimal Alpine stage that runs it as nobody.

# Stage 1: build
FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app .
# Stage 2: runtime
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
COPY --from=builder /app /app
USER nobody
ENTRYPOINT ["/app"]

Measured on a minimal Go program, as the size of the exported file system (docker export): the same build in a single stage on golang:1.25-alpine weighs 261.4 MB; as a multi-stage build, the final image weighs 10.8 MB, of which 8.6 MB is Alpine. The binary does run as the nobody user (uid=65534).

ImageContentsMeasured size
Single stageGo SDK, sources and binary261.4 MB
Multi-stageAlpine and binary10.8 MB

Node.js example: separating build and production

The same pattern applies to Node.js: the first stage installs the development dependencies and builds, the second reinstalls only the production ones and copies the dist/ folder.

# Stage 1: build, with the development dependencies
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime, production dependencies only
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

The choice of base image weighs as much as the multi-stage build. Measured on the Node.js 24 variants: node:24 weighs 1155.5 MB, node:24-slim 231.8 MB and node:24-alpine 173.2 MB. Every megabyte less is also one package less to scan and patch.

Principle #4: Run as non-root

By default, the process of a container runs as root. If an attacker compromises the application, they get root privileges inside the container, and the slightest isolation flaw becomes a door to the host. The USER instruction runs the application under an unprivileged user.

FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
WORKDIR /app
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "index.js"]

Some images already provide a user, but it is not always usable as is. Measured: the node image carries the node user (UID 1000), ready to use. The nginx image carries an nginx user, but a plain USER nginx makes the startup fail: nginx can no longer create /var/cache/nginx/client_temp, which belongs to root. For nginx, use the unprivileged variant published by the project, nginxinc/nginx-unprivileged, or prepare the permissions of these folders in the Dockerfile.

COPY --chown=user:group gives the files to the right user at copy time, without a separate RUN chown that would duplicate the files in one more layer. A non-root user is a start: in production, you complete it at launch with a read-only file system, dropped capabilities and no-new-privileges, detailed in the next lesson on Docker security.

Principle #5: Add a healthcheck

The HEALTHCHECK instruction declares a command that Docker runs regularly to find out whether the application responds, and not only whether its process exists. The container then goes through the states starting, healthy or unhealthy, visible in docker ps.

FROM nginx:1.29-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
COPY index.html /usr/share/nginx/html/
OptionMeaning
--interval=30sChecks every 30 seconds
--timeout=3sThe command must answer within 3 seconds
--start-period=5sAllows 5 s of startup before counting failures
--retries=33 consecutive failures = unhealthy

This mechanism belongs to Docker Engine, Compose and Swarm. Kubernetes does not read the HEALTHCHECK of an image: its probes (readinessProbe, livenessProbe, startupProbe) are declared in the manifest. In a Kubernetes environment, the HEALTHCHECK only serves on the developer workstation.

Terminal window
docker inspect --format '{{.State.Health.Status}}' mon_conteneur
# starting, healthy or unhealthy

Advanced best practices

The three practices below concern the security of the build more than its speed: reusing a cache without writing it into the image, passing a secret without shipping it, and knowing exactly which base image you build on.

Use BuildKit cache mounts

A cache mount keeps a package manager's cache between builds, without writing it into a layer of the image. Measured with Docker 29.1.3: a RUN --mount=type=cache builds without the # syntax=docker/dockerfile:1 line at the top of the file, because the frontend built into BuildKit understands --mount. The # syntax line is still useful to pin the frontend version, it is no longer required.

FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
COPY . .
# Python (pip)
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Go modules
RUN --mount=type=cache,target=/go/pkg/mod \
go build -o /app/main .

Keep secrets out of the image

A secret passed through ARG or ENV stays in the image metadata, even if the file that used it is deleted in the same instruction. Measured in the lesson on pipeline secrets: a token passed with --build-arg appears twice in docker history --no-trunc, and not once with a secret mount.

# ❌ The token stays readable in the image history
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && npm ci && rm .npmrc
# The secret only exists during this RUN, never in a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
Terminal window
docker build --secret id=npmrc,src=$HOME/.npmrc -t mon-app:1.4.0 .

Pin the versions of base images

A tag such as node:24-alpine designates the latest image published under that name: it changes with every update, without warning. The digest (@sha256:...) designates one image and one only, which makes the build reproducible and protects against a substituted image.

# ❌ Can change at any time
FROM node:24-alpine
# Readable tag and immutable digest
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
ReferenceExampleReproducibleSecurity fixes
Major tagnode:24NoReceived automatically, unchecked
Tag and variantnode:24-alpineNoReceived automatically, unchecked
Tag and digestnode:24-alpine@sha256:...YesDeliberate digest update

The trade-off of the digest is real: it receives no fix until you change it. A tool such as Renovate or Dependabot opens a pull request for each new version, which keeps reproducibility without freezing the flaws. To check a Dockerfile even before the build, the Hadolint (in French) linter flags these gaps.

Troubleshooting

Dockerfile problems almost all fall into three families: a cache that does not help, an image that is too heavy, or a permission error at startup. Start from the symptom, then check the cause with the measurement given.

Build still slow despite the right order

The cache does not help if an instruction placed early changes on every build. The usual causes are a context that is too wide, carrying files that move, and a COPY that is too wide.

  1. Context too wide: read the line transferring context of docker build --progress=plain; several tens of megabytes point to an incomplete .dockerignore.
  2. COPY *.json ./ too wide: it can include JSON files that change often. Be explicit: COPY package.json package-lock.json ./.

Multi-stage image still large

A heavy final image despite the multi-stage build almost always comes from the base image of the last stage or from files copied in excess.

  1. Base image: node:24 weighs 1155.5 MB, node:24-alpine 173.2 MB (measurements above).
  2. Development files copied: only copy dist/ or build/ from the build stage.
  3. Package manager cache: clean it in the same instruction, npm ci --omit=dev && npm cache clean --force, or use a cache mount.

"permission denied" error with a non-root USER

The container stops with EACCES or Permission denied as soon as it switches to non-root. The cause is almost always a file or folder that still belongs to root, rarely the port.

  1. Files owned by root: use COPY --chown=node:node.
  2. Runtime folder not accessible: this is the measured case of nginx with USER nginx, which cannot create /var/cache/nginx/client_temp.
  3. Low port: contrary to a common belief, it is not the cause. Measured, Docker sets ip_unprivileged_port_start to 0 in the container, and a server started with --user node listens on port 80 without error.

HEALTHCHECK always "unhealthy"

The container starts but stays unhealthy. Check, in this order, that the tested URL exists, that --start-period leaves time to start, and that the healthcheck command (wget, curl) exists in the image.

Terminal window
docker inspect --format '{{json .State.Health}}' mon_conteneur

Key points

  1. Instruction order: dependencies before source code; measured, npm ci comes from the cache and the build drops from 1.9 s to 0.5 s.
  2. .dockerignore: exclude node_modules, .git and secrets; measured, the context drops from 70 MB to 89 bytes.
  3. Multi-stage: separate build and runtime; measured, a Go image drops from 261 MB to 11 MB, without a compiler.
  4. Non-root USER: the failure comes from file permissions, not from the port, which Docker allows from 0.
  5. Build secrets: --mount=type=secret, never ARG or ENV, which stay in the history.
  6. Base image pinned by digest, updated through an automated pull request.

Test your knowledge

Check that you have the essentials of this guide: cache, .dockerignore, multi-stage, non-root, build secrets and pinning of the base image.

Knowledge check

Check what you have learned with this interactive quiz

7 questions
5 min
80% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn