Docker Multi-Stage Builds: Smaller Images and Faster CI Pipelines
A Node.js application shipped as a Docker image with all development dependencies included: node_modules with Jest, ESLint, TypeScript compiler, and hundreds of transitive dev dependencies baked in. The image weighs 1.8 GB. The actual runtime — Node.js plus the application’s production dependencies — needs 120 MB. The other 1.68 GB costs money in every registry push, every Kubernetes pod startup, every CI cache miss. Multi-stage builds fix this by separating what you need to build from what you need to run.
The Core Mechanism
A multi-stage Dockerfile has multiple FROM statements. Each FROM starts a new stage with a clean filesystem. You can name stages and copy specific files from one to another. Everything else in the earlier stage — build tools, compilers, test frameworks, intermediate artifacts — gets discarded automatically.
Basic structure:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
The --from=builder flag copies the compiled dist/ directory from the builder stage into the production image. The TypeScript compiler, the test runner, and every dev dependency never touch the final image. The production image contains only the runtime and the built application.
The image size difference is real: a typical TypeScript service drops from 800 MB (single-stage, dev deps included) to 150 MB with this pattern. For Go applications, the reduction is more dramatic — you compile in a golang image and copy a single static binary into scratch or distroless/static, ending up with an image under 15 MB.
Cache Layering: The Part That Affects Build Speed
Multi-stage builds give you smaller images. Layer caching gives you faster builds. Both matter for CI pipelines. If your builds take 15 minutes because npm install runs from scratch every time, the smaller image doesn’t help your developers.
Docker’s layer cache works by comparing each instruction to the cached result of the same instruction with the same inputs. If the instruction and its inputs haven’t changed, Docker reuses the cached layer. The critical implication: copy your dependency files first, install, then copy your source code.
Wrong order — cache busted on every code change:
# BAD: source code copied before dependencies installed
COPY . .
RUN npm ci
Right order — dependency installation cached until package.json changes:
# GOOD: dependencies installed from lockfile first
COPY package.json package-lock.json ./
RUN npm ci
COPY . . # This layer busts, but npm ci was cached
This pattern means npm ci only runs when package.json or package-lock.json changes — not when you edit a source file. In a CI pipeline that runs 20 times a day, this drops the average build from 8 minutes to 45 seconds for the dependency-heavy stages.
The same pattern applies to every language:
# Python: requirements first
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Go: go.mod and go.sum first
COPY go.mod go.sum ./
RUN go mod download
COPY . .
BuildKit Cache Mounts
Docker BuildKit (default since Docker 23.0) adds cache mounts that persist the package manager’s cache between builds — even when the lockfile changes. Standard layer caching only helps when the lockfile is identical. Cache mounts help even when you add a new dependency, because the already-downloaded packages from the registry stay cached.
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
The --mount=type=cache,target=/root/.npm line tells BuildKit to mount a persistent cache at /root/.npm for this instruction. The npm cache directory lives across builds. First build: full download. Second build after adding a dependency: only the new package downloads; everything else comes from the cache mount.
For Python with pip:
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
For Go:
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app/server ./cmd/server
Two cache mounts for Go: the module cache and the build cache. Together they make incremental Go builds fast even in CI, where Docker layers can’t be reused because the source has changed.
Cache mounts require BuildKit, enabled by setting DOCKER_BUILDKIT=1 or using docker buildx build. In modern Docker and most CI systems (GitHub Actions, GitLab CI), BuildKit is the default.
Real Examples by Language
Go: From 1 GB to Under 20 MB
Go applications compile to a static binary. You don’t need Go installed at runtime — just the binary.
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
distroless/static-debian12 contains nothing except the minimum CA certificates and timezone data needed to run a static binary. No shell, no package manager, no OS tools. Final image size: typically 8-20 MB for a real application. Attack surface: near zero, because there’s almost nothing to exploit.
-ldflags="-s -w" strips debug symbols and DWARF information — shaves another 30-40% off the binary size.
Node.js: Separate Build and Runtime
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Two cache mounts — one for each stage. The production stage installs only production dependencies (--omit=dev). The compiled JavaScript from the builder stage gets copied in. Running as node rather than root reduces the container’s privileges.
Python: Wheel Build Stage
Python packages with C extensions take time to compile from source. Build them once in a build stage, install the pre-compiled wheels in the runtime stage:
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip && \
pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/*.whl
COPY . .
USER nobody
CMD ["python", "app.py"]
The /wheels directory carries pre-compiled binaries. The runtime stage installs from those local wheels (--no-index) instead of downloading from PyPI. If you have packages like numpy, pandas, or cryptography that involve C compilation, this pattern makes the production image build dramatically faster.
.dockerignore: Cut Build Context First
Before any caching or multi-stage optimization matters, your build context needs to be lean. Every file in your project directory gets sent to the Docker daemon when you run docker build, unless .dockerignore excludes it. A missing .dockerignore regularly adds 500 MB-2 GB of unnecessary content: node_modules, __pycache__, .git, log files, test output.
A solid .dockerignore for a Node.js project:
node_modules
.git
.gitignore
*.md
.env*
coverage
.nyc_output
dist
logs
*.log
docker-compose*
.dockerignore
Dockerfile*
For Python:
__pycache__
*.pyc
*.pyo
.pytest_cache
.coverage
htmlcov
.env*
.git
venv
.venv
*.egg-info
dist
build
Verify the context size before and after with docker build --no-cache 2>&1 | head -5. The first line shows the context size being sent. Getting this from gigabytes to megabytes is often the single biggest improvement you can make to build speed.
Targeting Specific Stages
You can build only a specific stage from a multi-stage Dockerfile:
# Build just the test stage, don't produce a final image
docker build --target builder -t myapp:build .
# Run tests in the build stage
docker run --rm myapp:build npm test
# Build the production stage
docker build --target production -t myapp:latest .
This lets a single Dockerfile serve both CI testing and production image building. The test stage includes dev dependencies and test tools; the production stage doesn’t. Your CI pipeline runs:
# Run tests
docker build --target builder -t myapp:ci .
docker run --rm myapp:ci npm test
# Build and push production image only if tests pass
docker build --target production -t myapp:prod .
docker push myapp:prod
For integration with GitLab CI builds pushing to ECR, targeting specific stages in your pipeline configuration keeps the test image and the deployment image clearly separated.
Security Benefits
Smaller images are more secure — fewer packages means fewer vulnerable packages. A full node:20 image bundles Debian packages, shell utilities, and dozens of system libraries you don’t use. An Alpine-based image cuts most of that. A distroless image cuts nearly all of it.
Beyond attack surface, multi-stage builds enforce a separation you can audit. The production stage only contains what you explicitly copy in. If sensitive build tooling (credentials for private package registries, build-time secrets) gets introduced during the build stage, it doesn’t make it into the production image unless you copy it explicitly — and you shouldn’t.
Use BuildKit secret mounts for credentials that need to be present during build but must not appear in any layer:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci
Pass the secret at build time:
docker build --secret id=npm_token,src=.npmrc .
The token is available during the npm ci instruction and never written to any image layer. It doesn’t appear in docker history and cannot be extracted from the final image.
What to Measure
Three numbers to track before and after optimizing a Dockerfile:
- Image size (
docker images): The number you started with. Cut it by at least 50% with multi-stage. Cut it further with distroless or scratch base images. - Build time without cache (
time docker build --no-cache .): Represents worst-case CI with a cold cache. Improve it by ordering instructions well and using build cache mounts. - Build time with warm cache (
time docker build .): Represents the typical developer iteration loop. Improve it by getting theCOPY package.json / RUN installpattern right so dependency installs cache on unchanged lockfiles.
If you’re using Lambda container images, the size limits and cold start implications of larger images make multi-stage optimization directly visible in production latency.
Comments