Shipping TypeScript sources, package-manager caches, compilers, and test tooling inside a production image is easy. It is also a larger attack surface and slower pull for no runtime benefit. Multi-stage builds make the boundary explicit.
The pattern is one build stage carrying everything needed to compile, then a minimal runtime stage that receives only the built application and its production dependencies. Verify that runtime image from scratch—a successful build stage is no proof it contains what production actually needs.
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
| Stage | Contains |
|---|---|
| Build | Source, compiler, dev dependencies, caches |
| Runtime | Compiled output and runtime dependencies only |
A smaller image is not automatically secure. Keep the base image patched, run as a non-root user, and do not copy
.envfiles or build secrets into either stage.
Use a .dockerignore alongside this pattern: excluding node_modules, .git, test artifacts, and local secrets prevents them from becoming build context in the first place. Multi-stage builds are most useful when they enforce a deployable artifact, not when they become an elaborate size-optimization game.
Keep cache layers useful
Copy dependency manifests before application code so a source-only edit can reuse the dependency-install layer. With BuildKit, mount a package-manager cache for speed, but never copy secrets or credentials from it into the final stage.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Verify the runtime stage on its own
Run the final image with a read-only filesystem and a non-root user. Missing CA certificates, a native module compiled for the wrong libc, or an absent runtime file often hides behind a successful build stage. docker history should show source and compilers only in the build stage; if they appear in runtime, the boundary leaked.
Cover photo by Wolfgang Weiser on Pexels.
