Skip to main content
PProfileMatch

Writing a Good Dockerfile: Caching, Multi-Stage Builds and Non-Root

DevOpsLesson 2 of 45 min read

What you'll learn

  • Order Dockerfile instructions so dependency installs are cached between code edits
  • Write a .dockerignore file that keeps secrets and junk out of the build context
  • Use a multi-stage build to ship only runtime files
  • Run the application as a non-root user and handle signals properly

On this page

  1. Start with the naive version and see what is wrong
  2. Layer caching: order by how often things change
  3. .dockerignore: control what enters the build context
  4. Multi-stage builds: compile in one image, ship another
  5. Other choices worth making deliberately
  6. Common mistakes and the symptoms they cause
  7. How this shows up in real projects and interviews

Here is a build log that tells a story. A developer changes one line in a handler, runs docker build, and waits two minutes while npm install downloads the same four hundred packages again. Then the image turns out to be 1.1 GB, contains a .env file, and runs as root. Three separate problems, all fixable in the Dockerfile. This lesson uses a small Node and TypeScript service, a parcel-locker API, as the running example.

Start with the naive version and see what is wrong

Dockerfile (before)
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD npm start

It builds and it runs, so what is the harm? COPY . . runs before npm install, so editing any source file invalidates that layer and every layer after it, which forces a full reinstall. Dev dependencies and the TypeScript compiler stay in the final image. Everything in the project folder, including .git and .env, is baked in. The process runs as root. And CMD npm start uses shell form, so npm becomes the main process and may never forward a stop signal to your server.

Layer caching: order by how often things change

Docker reuses a cached layer only if its instruction and all inputs (including copied file contents) are unchanged, and only if every layer above it was also reused. So put slow, rarely changing steps first and fast, frequently changing steps last. For Node that means copying just the manifest and lockfile, installing, and only then copying source.

What the builder reports on the second build (illustrative)
=> CACHED [2/6] WORKDIR /app
=> CACHED [3/6] COPY package.json package-lock.json ./
=> CACHED [4/6] RUN npm ci
=> [5/6] COPY src ./src
=> [6/6] RUN npm run build

Only the steps after the source edit run again. The exact formatting of build output depends on your Docker version, but the pattern of cached early steps and re-run late steps is what a healthy Dockerfile gives you. Use npm ci rather than npm install in images: it installs exactly what the lockfile says and fails if the lockfile and package.json disagree.

.dockerignore: control what enters the build context

Before building, the client sends the build context (the directory you pass, usually .) to the builder. Anything not ignored can be copied by COPY . .. A .dockerignore next to the Dockerfile fixes two problems at once: faster context transfer and no accidental secrets in layers.

.dockerignore
node_modules
dist
coverage
.git
.env
.env.*
*.log
Dockerfile
compose.yaml
README.md

Multi-stage builds: compile in one image, ship another

A multi-stage Dockerfile has several FROM lines. Each starts a fresh stage, and COPY --from=<stage> pulls specific files out of an earlier one. Compilers, test tools and caches stay behind in the build stage and never reach the final image. Below, one stage compiles TypeScript, another installs production-only dependencies, and the last assembles the runtime image.

Dockerfile (after)
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Walk through the last stage. ENV NODE_ENV=production makes many libraries skip development behaviour. The three COPY lines bring in production dependencies, compiled output and the manifest, each owned by the unprivileged node user that the official Node images already define. USER node drops privileges for everything after it, including the running process. CMD uses exec form, a JSON array, so node is PID 1 and receives SIGTERM directly when you run docker stop.

Other choices worth making deliberately

DecisionSafer defaultTrade-off
Base imageA slim or alpine variant with a pinned version tagAlpine uses musl libc, which occasionally breaks native Node or Python packages; slim Debian images are bigger but more compatible
COPY vs ADDPrefer COPYADD also unpacks archives and fetches URLs, which is rarely what you want
Build-time secretsBuildKit secret mounts, not ARG or ENVValues in ARG and ENV can be read back from image metadata
Dependency cacheRUN --mount=type=cache,target=/root/.npm npm ciNeeds BuildKit (default in current Docker); cache lives on the builder, not in the image

Common mistakes and the symptoms they cause

  • Copying all source before installing dependencies. Symptom: every build reinstalls packages and takes minutes. Fix: copy manifest and lockfile first.
  • No `.dockerignore`. Symptom: the build log shows a huge context transfer (hundreds of megabytes or more), or docker history reveals a .env file inside an image you pushed. Fix: ignore .git, node_modules and env files.
  • Forgetting to copy the lockfile. Symptom: npm ci fails with an error that it can only install with an existing package-lock.json. Fix: include the lockfile in the COPY step.
  • Switching to a non-root user but leaving root-owned directories. Symptom: EACCES: permission denied, mkdir '/app/uploads' at runtime. Fix: create the directory and chown it before USER, or write to a volume or /tmp.
  • Shell-form `CMD`. Symptom: docker stop hangs for about ten seconds and then kills the container, because the shell never passed on SIGTERM. Fix: use exec form, and consider docker run --init if a library spawns child processes.

How this shows up in real projects and interviews

Reviewers look at Dockerfiles for exactly the things above: cache order, ignored files, a pinned base, a non-root USER. Interviewers like the question "why is my rebuild slow after changing one file?" because the answer (layer invalidation order) shows whether you understand caching rather than memorised commands. Expect to be asked what multi-stage builds buy you, and be ready to answer in terms of image size and attack surface, not just "it is a best practice".

Try it yourself

Take a small web service you have written in any language and write a two-stage Dockerfile for it. Build it twice, editing only one source file between builds, and note which steps were cached. Then add a .dockerignore and compare the context size reported by the builder before and after.

Show a hint

For compiled languages, copy the build output (for example a jar or a static binary) into a slim runtime stage. For interpreted ones, the second stage may only need installed production dependencies plus your source.

Key takeaways

  • Put rarely changing steps (dependency install) before frequently changing steps (copying source) to keep the build cache useful.
  • A .dockerignore keeps secrets, VCS history and node_modules out of the build context and the image.
  • Multi-stage builds let you compile with heavy tools and ship only the runtime files.
  • Create and use a non-root user, and make sure the directories it writes to are owned by it.
  • Use exec-form CMD so your process is PID 1 and receives stop signals.

Test yourself

A trick from this lesson's ride: Slow and stable first, fast and fickle last. Docker rebuilds from the first change downward. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

How do I make Docker builds faster?

Order Dockerfile instructions so dependency installation comes before copying frequently changed source files, add a .dockerignore to shrink the build context, and use BuildKit cache mounts for package managers. A correct order means most rebuilds reuse cached layers.

What is a multi-stage build in Docker?

A multi-stage build uses several FROM instructions in one Dockerfile. Early stages compile or install things; the final stage copies only the needed artifacts with COPY --from. The result is a smaller image without compilers or test tools.

Why should a Docker container not run as root?

If an attacker exploits the application, running as root inside the container gives them more power over files, mounted volumes and, in the worst case, a kernel escape. A non-root USER limits the damage and is required by many hardened platforms.