Multi-stage dockerfiles for Node

A naive Dockerfile installs TypeScript, all dev dependencies, and your source code into the production image. That is wasteful and risky. Production only needs the compiled JavaScript and the runtime dependencies. Multi-stage builds let you do the compile step in one image and copy only the output into a smaller runtime image.

Two stages, one small image

The builder stage has everything. The runner stage copies only the compiled output and runtime deps. The result is a smaller, safer image.

order-service/Dockerfile
dockerfile
# Stage 1: builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Stage 1 builds the TypeScript. Stage 2 copies only the compiled output and production deps.

Read the file top to bottom. The builder stage installs everything, compiles TypeScript, and has no further job. The runner stage is a clean base image, installs only production dependencies, copies the compiled output from the builder, and switches to the non-root node user. The final image is smaller, faster to push, and has a smaller attack surface.

Always set USER node at the end. The default Docker user is root. If a vulnerability in your Node app lets an attacker run code inside the container, you do not want them to have root privileges. A three-character line that upgrades the security posture of every image you ever build.

One supporting file worth knowing: .dockerignore. It tells Docker which files not to copy into the image. Always ignore node_modules, .env, .git, and anything else sensitive. Without it, you risk shipping secrets or bloating the build context to gigabytes.

Quiz: Quiz

Loading practice…