EduFlow

Section 2 · Production-Grade Images

Multi-Stage Builds and .dockerignore

20 min3 min readSpot the bug

Lesson at a glance

Split the notes-api Dockerfile into build, dependency and runtime stages so the shipped image holds only compiled code and production dependencies, and keep junk out of the build context with .dockerignore.

  1. 1One Dockerfile, several stages
  2. 2Using intermediate stages on purpose
  3. 3.dockerignore: control what the builder sees

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

The image from section 1 ships TypeScript, the type definitions, your test runner, your src/ folder, and every other dev dependency to production. None of it runs. All of it is attack surface, pull time and scanner noise. The fix is to build in one place and ship from another.

One Dockerfile, several stages

Each FROM starts a new stage with a fresh filesystem. Name stages with AS, and copy files between them with COPY --from. Only the last stage becomes the image you tag.

Dockerfile
# syntax=docker/dockerfile:1

# 1. Compile TypeScript (needs dev dependencies)
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# 2. Production dependencies only
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

# 3. What actually ships
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

The runtime stage gets exactly three things: package.json, production node_modules, and compiled dist/. TypeScript, src/ and the npm cache stay behind in stages nobody ships.

The build stage compiles src to dist with all dependencies. The deps stage installs production dependencies only. The runtime stage copies dist from build and node_modules from deps. build npm ci (all deps) tsc: src → dist deps npm ci --omit=dev runtime (shipped) package.json node_modules (prod) dist/ dist/ node_modules left behind: TypeScript, src/, npm cache
Stages build in parallel; the runtime stage copies only what it needs

BuildKit works out the dependency graph between stages. build and deps don’t depend on each other, so they run in parallel, and a stage the target doesn’t need is skipped entirely.

On notes-api this took the image down by roughly a third, and the vulnerability scan you’ll run later in this section went from dozens of findings in dev tooling to a handful in the runtime. The next lesson shrinks the base itself.

Why copy package.json into the runtime at all? notes-api’s package.json contains "type": "module", which tells Node to treat .js files as ES modules. Leave it out and the container fails on start with SyntaxError: Cannot use import statement outside a module, which looks like a build bug but is a missing file. If your project uses CommonJS you could skip it, but it costs nothing and keeps npm metadata available.

You may see a different pattern: one stage that runs npm ci, builds, then runs npm prune --omit=dev before copying node_modules out. It works, and it saves one install. I prefer the separate deps stage because it caches on its own: bumping a dev-only tool like a linter doesn’t invalidate the production dependency layer, so that layer is reused across many builds and the runtime image changes less often.

Using intermediate stages on purpose

Stages are also handy targets. Run the test suite in the environment that has dev dependencies, without touching the shipped image:

Terminal
docker build --target build -t notes-api:test .
docker run --rm notes-api:test npm test

--target build stops at the build stage. CI can run this before building the runtime image, and both builds share the cache.

.dockerignore: control what the builder sees

When you run docker build ., the whole directory is sent to the builder as the context, minus anything matched by .dockerignore. Without one, your local node_modules (hundreds of megabytes, possibly with macOS-compiled native modules), the .git folder and any .env file with real credentials all travel to the builder. Any COPY . . puts them in a layer.

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

Three wins at once: the context drops from hundreds of megabytes to a few hundred kilobytes, edits to ignored files no longer bust the cache, and secrets sitting in your working tree can’t be copied into an image by accident.

The syntax is like .gitignore: one pattern per line, * and ** wildcards, and ! to re-include something. Keep it in the root of the build context. If you need one ignored file after all, re-include it below the broad rule, for example .env.* followed by !.env.example, so a template file can still be copied while real ones can’t.

Check what the builder actually received when in doubt: docker build --progress=plain . prints the size of the transferred context on its first lines. If that number is in megabytes for a small API, something is missing from .dockerignore.

Next you’ll swap the base image for a smaller one and stop running as root.

Key takeaways

  • A multi-stage build uses several FROM stages; only the last stage (or the one you --target) becomes the image.
  • COPY --from=<stage> pulls specific files out of an earlier stage, so compilers and dev dependencies never reach production.
  • Install production dependencies with npm ci --omit=dev in their own stage and copy only node_modules and dist into the runtime stage.
  • A .dockerignore keeps node_modules, .git, .env files and build output out of the context, making builds faster and safer.

Further reading

Check your understanding

4 questions

Question 1 of 4

In a Dockerfile with stages build, deps and runtime (in that order), what does docker build -t notes-api:1.0.0 . produce?

Question 2 of 4

Why install production dependencies in a separate deps stage instead of pruning them in the build stage?

Question 3 of 4

Your build context is 1.2 GB and every build starts with “transferring context” for 40 seconds. What’s the most likely fix?

Question 4 of 4

How do you run notes-api’s unit tests inside the image’s build environment without shipping test tooling?

Practice

Spot the bug

The multi-stage build that ships everything

This two-stage Dockerfile was meant to keep TypeScript and dev dependencies out of production. The image is barely smaller than before and the scanner still reports CVEs in test tooling. Select the line that defeats the purpose of the second stage.

Select the line(s) you think are wrong, then check.