Docker Multi-Stage Builds for Smaller Images
Use Docker multi-stage builds to ship tiny production images — build with full toolchains, copy only the artifacts. Examples for Node and Go, with .dockerignore and size comparisons.
What you'll learn
- ✓Why naive Dockerfiles produce huge images
- ✓The FROM ... AS builder syntax and how stages work
- ✓How COPY --from=builder pulls artifacts between stages
- ✓Real multi-stage examples for Node.js and Go
- ✓How .dockerignore reduces build context and image size
- ✓How to measure and compare image sizes
Prerequisites
- •You can write a basic Dockerfile — see Dockerfile Basics
- •Familiar with layers — see Docker Images, Layers, and Volumes
A naive Dockerfile for a Node app weighs in at 800MB. The same app, built with a multi-stage Dockerfile, can ship at 80MB or less. For a Go app, the difference is even more dramatic — from a 900MB build image to a 15MB production image.
The trick is multi-stage builds: do the heavy lifting in one image, then copy only the finished artifacts into a clean, tiny image. This post shows you how.
The problem with single-stage builds
Here is a typical first-attempt Dockerfile for a Node app:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Build it:
docker build -t myapp:single .
docker images myapp
# REPOSITORY TAG SIZE
# myapp single 1.12GB
Over a gigabyte. Where did that go?
- The
node:20base image ships with a full Debian userland — about 400MB. npm installpulls indevDependencies(TypeScript, webpack, eslint, jest) — another 200MB+ of stuff you don’t need at runtime.- Build tools like
python,make, andgcccome along for native module compilation. - Source files, test files,
.git, and editor cruft fromCOPY . ..
You ship and run all of that on every server. The build tools, the dev dependencies, the source code — none of it is needed at runtime. It’s just bloat.
The multi-stage idea
Use two FROM lines in the same Dockerfile. The first stage installs everything, builds the app, and produces an artifact. The second stage starts from a clean slim base and copies only the artifact.
The minimal syntax:
FROM node:20 AS builder
# ... do heavy work here ...
FROM node:20-alpine
COPY --from=builder /app/dist /app/dist
Two things to notice:
AS builder— gives the first stage a name you can refer to later. You can use any name.COPY --from=builder— copies a path from the named stage into the current stage. The builder is discarded at the end; nothing from it ships unless you explicitly copy it.
The result is an image based on node:20-alpine (about 50MB) containing only your build output. Everything else stays behind.
A real Node example
A small TypeScript Express app. Project layout:
my-node-app/
package.json
tsconfig.json
src/
server.ts
Dockerfile
.dockerignore
A proper multi-stage Dockerfile:
# ---- Stage 1: build ----
FROM node:20 AS builder
WORKDIR /app
# Install dependencies first (cached unless lockfile changes)
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Remove dev dependencies for the next stage to copy
RUN npm prune --omit=dev
# ---- Stage 2: runtime ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
# Copy only what production needs
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
Build and compare:
docker build -t myapp:multi .
docker images myapp
# REPOSITORY TAG SIZE
# myapp single 1.12GB
# myapp multi 180MB
A 6x reduction with no application changes. Some details worth calling out:
npm ciinstead ofnpm install— reproducible installs frompackage-lock.json. Faster too.- Two
COPYsteps forpackage*.jsonthensrc— leveraging the layer cache. If only source changes, thenpm cilayer is reused. npm prune --omit=devin the builder strips dev dependencies before the runtime stage copiesnode_modules.node:20-alpineas the runtime base — about 50MB, vs the 400MB full Debian-basednode:20.USER noderuns as a non-root user. Always do this in production.
An even more dramatic example: Go
Go binaries are statically compiled — they need almost nothing at runtime. This unlocks an extreme version of multi-stage:
# ---- Stage 1: build ----
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# ---- Stage 2: runtime ----
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
FROM scratch is the empty image — no shell, no libc, nothing but your binary. Build and measure:
docker build -t goapp:multi .
docker images goapp
# REPOSITORY TAG SIZE
# goapp multi 14MB
Fourteen megabytes for a complete production image. The build stage uses the full 900MB golang:1.22 toolchain; the final image contains a single binary and nothing else.
If you need TLS certificates (any app making HTTPS calls), add them explicitly:
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
For Go apps that need a shell for debugging or run scripts, use alpine instead of scratch:
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Still tiny (~20MB), but with a shell and package manager when you need them.
.dockerignore — keeping the build context small
Every docker build sends the entire context (the folder you point it at) to the Docker daemon, then COPY . . can pull any of it into the image. A 2GB .git directory or a 500MB node_modules slows every build and risks ending up in the image.
Create a .dockerignore next to your Dockerfile:
.git
.gitignore
node_modules
npm-debug.log
.env
.env.*
.DS_Store
*.md
README.md
.vscode
.idea
dist
coverage
test
__tests__
*.test.ts
Dockerfile
.dockerignore
This list:
node_modules— never copy host node_modules into the build. They’ll be installed inside..git— usually huge, never needed at runtime..env— keep secrets out of images.Dockerfileand.dockerignore— not needed inside the image.
Every project should have a .dockerignore. It speeds up builds, shrinks images, and protects against accidentally shipping secrets.
Try it yourself. In a Node project, run docker build . once without a .dockerignore, then again after adding one that excludes node_modules and .git. Compare the “Sending build context to Docker daemon” line at the top of the output. The difference is often gigabytes.
More than two stages
You are not limited to two stages. A common pattern adds a dependency-only stage that maximises cache reuse:
FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
This isolates the slow npm ci step in its own stage so it is reused across builds when only source code changes. The framework Next.js uses an almost identical pattern in its official Docker template.
You can also build a specific stage on demand:
docker build --target builder -t myapp:builder .
Useful for CI pipelines that need to run tests in the build environment without producing a final image.
Measuring and comparing
Quick size comparison:
docker images | grep myapp
# myapp single 1.12GB 3 hours ago
# myapp multi 180MB 2 hours ago
# myapp latest 180MB 2 hours ago
To see where the bytes go inside an image:
docker history myapp:multi
# IMAGE CREATED CREATED BY SIZE
# abc.. 2h ago CMD ["node" "dist/server.js"] 0B
# abc.. 2h ago COPY --from=builder /app... 120MB
# abc.. 2h ago COPY --from=builder /app... 8MB
# ...
Each row is a layer. The biggest one is usually node_modules — that’s where to focus your next round of optimization.
For deeper exploration, the third-party tool dive is fantastic:
brew install dive # macOS
# or download from https://github.com/wagoodman/dive
dive myapp:multi
It shows every layer’s contents and highlights wasted space (files that get added in one layer and deleted in a later one — those still bloat the image).
Try it yourself. Take any single-stage Dockerfile you have and rewrite it as a multi-stage build. Use a slim base for the runtime stage. Run docker images before and after to compare. Aim for a 3-5x reduction at minimum.
A few production tips
- Always pin base image tags to specific versions:
node:20.11-alpine, notnode:latest. Reproducible builds matter. - Use
alpineruntime bases unless you hit glibc-specific issues (some prebuilt Node modules need glibc and will not work on Alpine —node:20-slimis a good in-between). - Run as a non-root user with
USER. Every official image (node,postgres,nginx) ships with a suitable user already created. - Set
ENV NODE_ENV=production(and equivalents for other languages) so frameworks skip dev mode at startup. - Don’t
COPY .if you can avoid it. Copy specific directories so you do not pull in test files, docs, or build configs.
Recap
You now know:
- Naive single-stage builds ship build tools, dev dependencies, and source code as bloat.
- Multi-stage builds use multiple
FROM ... AS nameblocks; only the final stage becomes the image. COPY --from=builder /pathpulls artifacts from a named earlier stage.- Node apps shrink ~6x with a multi-stage build to
node:20-alpine. - Go apps shrink ~50x by building in
golang:1.22and shipping toscratch. .dockerignoreis essential — it keeps the build context (and the image) clean and small.- More than two stages can isolate slow steps for better cache reuse.
- Use
docker images,docker history, anddiveto measure where the bytes go.
Next steps
You have now covered the full Linux, Git, and Docker workflow — from basic commands and branches to multi-container apps and tiny production images. A natural next direction is deploying what you have built. Pick a small container, ship it to a real host, and watch it run.
For more on the foundations, revisit:
- Linux Process Management for keeping services healthy
- Git Remotes and Pull Requests for collaborating on what you ship
- Docker Compose Basics for orchestrating the rest of the stack
Questions or feedback? Email codeloomdevv@gmail.com.