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. Because this is where most of the actual production-readiness decisions in a containerized system get made, it's worth going deep here.
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.
A more realistic multi-stage build: separating dependency stages too
Real applications often benefit from splitting dependency installation itself into its own stage, particularly when a project has both build-only and runtime dependencies declared separately:
# Stage 1: install all dependencies (needed to build)
FROM node:20 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: build, reusing the installed dependencies
FROM node:20 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: install only production dependencies
FROM node:20 AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 4: the actual runtime image
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]Four stages, each with a single clear responsibility, and only the last one produces the image that actually ships. npm ci --omit=dev in its own dedicated stage means the runtime image's node_modules never included devDependencies in the first place — a cleaner separation than installing everything once and hoping a later step removes exactly the right things.
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 defaultalpine 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"]Healthchecks: telling the outside world when the container is actually ready
A container can be "running" (the process started) without the application inside it actually being ready to handle traffic — still connecting to a database, still warming a cache. HEALTHCHECK gives orchestration tooling (Compose, Kubernetes, a load balancer) a real signal to check, rather than assuming a running process means a ready application:
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node healthcheck.js || exit 1
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]docker psCONTAINER ID IMAGE STATUS
a1b2c3d4e5f6 my-app Up 2 minutes (healthy)
That (healthy) status is directly what makes the condition: service_healthy dependency pattern possible — covered in the Compose post later in this series — and it's the same signal a Kubernetes readiness probe checks before routing real traffic to a pod, covered in our Kubernetes series.
Build arguments vs. environment variables
It's worth being precise about the difference between ARG and ENV, because conflating them is a common source of confusion:
ARG NODE_ENV=production
ARG APP_VERSION
FROM node:20-slim
ENV NODE_ENV=${NODE_ENV}
ENV APP_VERSION=${APP_VERSION}
LABEL org.opencontainers.image.version="${APP_VERSION}"docker build --build-arg APP_VERSION=$(git rev-parse --short HEAD) -t my-app:latest .ARG values exist only at build time — available while the Dockerfile's instructions run, gone once the image is built, unless explicitly passed into ENV to persist into the running container. ENV values are baked into the image and available to the running container's process. Passing the current git commit SHA as a build arg, then labeling the image with it, is a genuinely useful pattern for traceability: given a running container, you can always answer "exactly which commit is this" without guessing.
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).
Reducing build time further: BuildKit cache mounts
Beyond layer caching, BuildKit (Docker's modern build engine, enabled by default in recent versions) supports cache mounts — a cache that persists across builds without becoming part of any image layer at all:
# syntax=docker/dockerfile:1
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build--mount=type=cache,target=/root/.npm persists npm's own download cache between builds, even when the package-lock.json layer itself gets invalidated by a dependency change — meaning even a dependency bump only re-downloads what actually changed, rather than npm's entire cache starting cold. This is a meaningfully different mechanism from ordinary layer caching: the cache mount's contents never become part of the image itself, so it doesn't affect image size at all, only build speed.
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; splitting dependency installation into its own stage sharpens this further.
- Choose a base image deliberately —
alpine's size savings come with a realmuslcompatibility trade-off, not just a free win. - Run as a non-root user, and add a
HEALTHCHECKso orchestration tooling can tell "running" apart from "actually ready." ARGis build-time only;ENVpersists into the running container — use build args for things like a commit SHA baked into an image label for traceability.- BuildKit cache mounts speed up builds without affecting final image size — a genuinely different lever from layer caching.
- Always write a
.dockerignore— it's cheap, and it closes a real, common secret-leakage gap.
Next in the series: Docker Networking Explained, where we cover how containers actually talk to each other and to the outside world.
