NovuSpark
All articles

October 24, 2025 · NovuSpark Team

Writing Production-Ready Dockerfiles (Multi-Stage Builds)

This is the second post in our Docker fundamentals series. Start with images, containers, and the engine if you're joining partway through.

A Dockerfile that works on a developer's laptop and a Dockerfile that's genuinely appropriate to ship to production optimize for different things. The first optimizes for "gets the app running quickly." The second optimizes for image size, build speed, and attack surface — and those three goals turn out to reinforce each other more than most people expect.

The naive version, and what's actually wrong with it

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

This runs. It also ships an image containing the full Node.js toolchain, every devDependency, your .git directory if you forgot a .dockerignore, and — because layer order puts COPY . . before npm install — invalidates the dependency-install cache layer on every single source code change, even a one-line CSS edit.

Fixing layer order first

From the previous post in this series: Docker caches layers by content, and a change to any layer invalidates every layer after it. The fix is copying only what a given step actually needs, in the order that changes least frequently first:

FROM node:20
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm ci
 
COPY . .
CMD ["node", "server.js"]

Now npm ci only re-runs when package.json or package-lock.json actually change — a source-only edit reuses the cached dependency-install layer entirely, turning a multi-minute rebuild into a few seconds.

Multi-stage builds: the actual production fix

The remaining problem is that the final image still contains the entire Node.js toolchain, npm's cache, and anything else needed to build the application — none of which is needed to run it. A multi-stage build separates "build" from "run" into distinct stages, and only copies the finished output between them.

# Stage 1: build
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
 
# Stage 2: run
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

COPY --from=build reaches into the first stage and copies out only the compiled output — the final image never contains the TypeScript compiler, the build tool, or any devDependency, because none of that ever existed in the second stage to begin with. This routinely cuts a real application image from several hundred megabytes down to a fraction of that size, and every megabyte removed is also attack surface removed — a package that was never installed can't have a vulnerability.

Choosing a base image deliberately

FROM node:20            # ~1.1 GB — full Debian, every common build tool
FROM node:20-slim       # ~250 MB — Debian, stripped of most non-essential packages
FROM node:20-alpine     # ~180 MB — musl libc instead of glibc, minimal by default

alpine variants are smaller because they're built on Alpine Linux rather than Debian, using musl instead of glibc — usually a safe swap, but not always: some native Node modules compiled against glibc behave differently or fail outright on musl, which is worth knowing before choosing alpine reflexively for every project. slim variants are the pragmatic middle ground: meaningfully smaller than the full image, without the musl compatibility question.

Running as a non-root user

By default, a container's main process runs as root inside the container — the exact same UID that would be catastrophic if it were ever able to escape the container's isolation. Explicitly dropping to an unprivileged user removes a real (if narrow) category of risk:

FROM node:20-slim
WORKDIR /app
 
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
 
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser
 
EXPOSE 3000
CMD ["node", "dist/server.js"]

A .dockerignore is not optional

# .dockerignore
node_modules
.git
.env
*.md
Dockerfile

Without this, COPY . . includes your local node_modules (platform-specific binaries that may not match the container's OS), your entire git history, and potentially a .env file containing real secrets — copied straight into an image layer, recoverable by anyone who can pull that image, forever, even if a later layer "deletes" the file (deleting a file in a later layer hides it from the final filesystem, it doesn't remove it from the layer where it was added).

What to actually remember from this post

  • Order Dockerfile instructions from least-frequently-changed to most-frequently-changed — this is what makes layer caching actually save time.
  • Multi-stage builds separate build tooling from the runtime image — the single biggest lever for both image size and attack surface.
  • Choose a base image deliberatelyalpine's size savings come with a real musl compatibility trade-off, not just a free win.
  • Run as a non-root user, and always write a .dockerignore — both are cheap, both close real gaps.

Next in the series: Docker Networking Explained, where we cover how containers actually talk to each other and to the outside world.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.