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
- Start with the naive version and see what is wrong
- Layer caching: order by how often things change
- .dockerignore: control what enters the build context
- Multi-stage builds: compile in one image, ship another
- Other choices worth making deliberately
- Common mistakes and the symptoms they cause
- 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
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD npm startIt 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.
=> 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 buildOnly 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.
node_modules
dist
coverage
.git
.env
.env.*
*.log
Dockerfile
compose.yaml
README.mdMulti-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.
# 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
| Decision | Safer default | Trade-off |
|---|---|---|
| Base image | A slim or alpine variant with a pinned version tag | Alpine uses musl libc, which occasionally breaks native Node or Python packages; slim Debian images are bigger but more compatible |
| COPY vs ADD | Prefer COPY | ADD also unpacks archives and fetches URLs, which is rarely what you want |
| Build-time secrets | BuildKit secret mounts, not ARG or ENV | Values in ARG and ENV can be read back from image metadata |
| Dependency cache | RUN --mount=type=cache,target=/root/.npm npm ci | Needs 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 historyreveals a.envfile inside an image you pushed. Fix: ignore.git,node_modulesand env files. - Forgetting to copy the lockfile. Symptom:
npm cifails with an error that it can only install with an existingpackage-lock.json. Fix: include the lockfile in theCOPYstep. - Switching to a non-root user but leaving root-owned directories. Symptom:
EACCES: permission denied, mkdir '/app/uploads'at runtime. Fix: create the directory andchownit beforeUSER, or write to a volume or/tmp. - Shell-form `CMD`. Symptom:
docker stophangs for about ten seconds and then kills the container, because the shell never passed onSIGTERM. Fix: use exec form, and considerdocker run --initif 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.
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.