docker

Multi-Stage Dockerfile

Multi-stage Dockerfile pattern for minimal production images with separate build, test, and runtime stages.

Overview

Multi-stage builds use multiple FROM statements to separate build-time dependencies from the final runtime image. This results in dramatically smaller, more secure production images while maintaining a single Dockerfile for all environments.

Configuration

# Dockerfile

# ══════════════════════════════════════
# Stage 1: Dependencies
# ══════════════════════════════════════
FROM node:20-alpine AS deps

WORKDIR /app

# Copy dependency manifests
COPY package.json package-lock.json ./

# Install all dependencies (including dev for building)
RUN npm ci

# ══════════════════════════════════════
# Stage 2: Build
# ══════════════════════════════════════
FROM deps AS build

WORKDIR /app

# Copy source code
COPY tsconfig.json ./
COPY src/ ./src/
COPY public/ ./public/

# Build the application
RUN npm run build

# Prune devDependencies after build
RUN npm prune --omit=dev

# ══════════════════════════════════════
# Stage 3: Test (optional, for CI)
# ══════════════════════════════════════
FROM deps AS test

WORKDIR /app

# Copy everything needed for tests
COPY . .

# Run tests — this stage fails the build if tests fail
RUN npm run test -- --ci --coverage

# Run linting
RUN npm run lint

# ══════════════════════════════════════
# Stage 4: Production
# ══════════════════════════════════════
FROM node:20-alpine AS production

# Install tini for signal handling and dumb-init alternative
RUN apk add --no-cache tini

# Create non-root user
RUN addgroup -S app && adduser -S app -G app

WORKDIR /app

# Copy only production dependencies (pruned in build stage)
COPY --from=build /app/node_modules ./node_modules

# Copy built output
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./

# Set environment
ENV NODE_ENV=production
ENV PORT=3000

# Switch to non-root user
USER app

EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD node -e "fetch('http://localhost:3000/health').then(r => {if(!r.ok) process.exit(1)})"

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

# ══════════════════════════════════════
# Stage 5: Development
# ══════════════════════════════════════
FROM deps AS development

WORKDIR /app

# Copy source (will be overridden by volume mount in docker-compose)
COPY . .

EXPOSE 3000 9229

CMD ["npx", "nodemon", "--inspect=0.0.0.0:9229", "src/index.ts"]

Key Options Explained

  • FROM ... AS name — Names each stage so other stages can reference it with COPY --from=name. Only the final targeted stage ends up in the output image.
  • docker build --target production . — Builds only up to the specified stage. Use --target development for dev, --target test for CI.
  • Stage separation — The deps stage is shared between build, test, and development, so dependencies are only installed once.
  • npm prune --omit=dev — After building, removes devDependencies so only production deps are copied to the final image.
  • Test stage — Runs during CI with docker build --target test .. If tests fail, the build fails. Does not contribute to the production image.
  • Each stage starts fresh — Only explicitly COPY --from artifacts carry between stages. Build tools, source code, and dev dependencies are automatically excluded from production.

Common Modifications

  • Go application: Replace Node stages with FROM golang:1.22-alpine AS build and FROM scratch AS production for an ultra-minimal binary image.
  • Frontend + Backend: Add a frontend-build stage with FROM node:20-alpine AS frontend-build that builds the React/Vue app, then copy the output to the backend stage.
  • CI integration: Use docker build --target test . in CI pipelines. Extract coverage reports with docker cp.
  • Build arguments: Add ARG VITE_API_URL before build commands to inject environment-specific values at build time.
  • Cache mounts: Use RUN --mount=type=cache,target=/root/.npm npm ci to cache npm downloads across builds (BuildKit required).