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.
- 1One Dockerfile, several stages
- 2Using intermediate stages on purpose
- 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.
# 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.
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:
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.
node_modules
dist
coverage
.git
.env
.env.*
*.log
Dockerfile
compose*.yamlThree 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
FROMstages; 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=devin their own stage and copy onlynode_modulesanddistinto the runtime stage. - A
.dockerignorekeepsnode_modules,.git,.envfiles and build output out of the context, making builds faster and safer.