Building Production-Ready Docker Images: A Multi-Stage Build Guide
I’ve shipped Docker images to production for years now, and the single biggest improvement I’ve made wasn’t some fancy orchestration tool or a new CI platform. It was learning to write proper multi-stage Dockerfiles. My CI pipeline used to spend 20 minutes rebuilding a bloated 2GB image every push. After switching to multi-stage builds, that image dropped to 45MB and builds finished in under 3 minutes. That’s not a typo.
If you’re new to Docker, start with my Docker quickstart first. This article assumes you’re comfortable building images and running containers. We’re going deeper.
Stop Using ubuntu:latest as Your Base Image
I see this constantly. People reach for ubuntu:latest or node:18 as their base and never think twice. Those images are enormous. They’re packed with utilities you’ll never use in production, and every extra binary is another potential attack surface.
Here’s what a typical naive Dockerfile looks like:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
That image? Probably 900MB+. Your server.js might be 50 lines of code. The ratio is absurd.
The fix isn’t complicated. Use slim variants at minimum, and ideally use distroless or scratch images for your final stage. I’ve seen teams shave 800MB off their images just by switching the base. That’s 800MB less to push, pull, store, and scan. It compounds across every service, every deploy, every environment. More on the specific options shortly.
Multi-Stage Builds: The Core Idea
Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Each FROM starts a new stage. You can copy artifacts from one stage to another and discard everything else. Your build tools, dev dependencies, source code — none of it ends up in the final image.
Here’s a Go example that demonstrates this perfectly:
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .
# Production stage
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The builder stage has the entire Go toolchain. The final image has literally nothing except your compiled binary. No shell, no package manager, no libc. Just your application. That image might be 8MB.
I use scratch for Go because Go compiles to static binaries. For languages that need a runtime, scratch won’t work — but distroless will. The key insight is that your build environment and your runtime environment have completely different requirements. Your build needs compilers, headers, package managers. Your runtime needs your binary and maybe a language interpreter. Multi-stage builds let you honour that distinction cleanly.
Distroless Images for Runtime Languages
Google’s distroless images contain only your application and its runtime dependencies. No shell, no package managers, no curl. They’re not as tiny as scratch, but they work for languages like Python, Java, and Node.
Here’s a Node.js multi-stage build using distroless:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
EXPOSE 3000
CMD ["server.js"]
And a Python example:
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
COPY . .
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --from=builder /app .
EXPOSE 8000
CMD ["app.py"]
The pattern’s the same every time. Build in a fat image, copy only what you need into a minimal one.
Layer Caching: Order Matters More Than You Think
Docker caches each layer. When a layer changes, every layer after it gets rebuilt. This means the order of your Dockerfile instructions has a massive impact on build times.
The most common mistake I see: copying your entire source tree before installing dependencies.
# Bad - busts cache on every code change
COPY . .
RUN npm ci
# Good - dependencies cached unless package.json changes
COPY package*.json ./
RUN npm ci
COPY . .
That simple reorder can save minutes per build. Your dependencies don’t change on every commit, but your source code does. Copy the dependency manifests first, install, then copy everything else.
For Go, the same principle applies:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/server .
go mod download only reruns when go.mod or go.sum change. Your actual compilation reruns on code changes, but the dependency download is cached. On a project with hundreds of dependencies, that’s a big deal.
.dockerignore: The File Everyone Forgets
You wouldn’t ship your node_modules, .git directory, or local env files to production. But without a .dockerignore, that’s exactly what COPY . . does.
Here’s a .dockerignore I use on most projects:
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
docker-compose*.yml
.dockerignore
.env*
*.md
.vscode
.idea
__pycache__
*.pyc
.pytest_cache
coverage
.nyc_output
dist
build
This isn’t just about image size. Copying .git into your build context can add hundreds of megabytes to the data Docker has to process before it even starts building. I’ve seen build contexts balloon to 1GB+ because someone forgot this file. If your docker build seems slow before it even executes the first instruction, check your build context size.
Run as Non-Root. Always.
By default, containers run as root. That’s a terrible idea for production. If someone exploits a vulnerability in your app, they’ve got root inside the container. Combined with a misconfigured volume mount or a kernel exploit, that’s a path to the host.
For more on this topic, I’ve written about container security in depth.
Adding a non-root user takes three lines:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
USER nonroot
EXPOSE 3000
CMD ["server.js"]
Distroless images already include a nonroot user. For Alpine-based final images:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
That’s it. No excuse to skip this.
Security Scanning with Trivy
Building a small image is great, but you still need to know what vulnerabilities are lurking in your dependencies and base image. Trivy is my go-to scanner. It’s fast, it’s free, and it catches things you’d never find manually.
Run it against any image:
trivy image myapp:latest
You’ll get a report of CVEs grouped by severity. I run this in CI and fail the build on HIGH or CRITICAL findings:
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
You can also scan your Dockerfile itself for misconfigurations:
trivy config Dockerfile
This catches things like running as root, using latest tags, or missing health checks. It’s not a replacement for understanding what you’re doing, but it’s a solid safety net. I’ve caught misconfigured COPY instructions and exposed secrets this way before they ever hit a registry.
One thing I’ve learned the hard way: scan regularly, not just at build time. A base image that was clean last week might have new CVEs today. I schedule nightly scans of all production images and pipe the results into Slack.
BuildKit: Use It
Docker BuildKit is the modern build engine. It’s faster, supports better caching, and enables features like secret mounting and SSH forwarding during builds. If you’re not using it, you’re leaving performance on the table.
Enable it:
export DOCKER_BUILDKIT=1
Or set it permanently in /etc/docker/daemon.json:
{
"features": {
"buildkit": true
}
}
BuildKit gives you parallel stage execution in multi-stage builds. If your stages don’t depend on each other, they build simultaneously. It also gives you better cache management and inline cache metadata for CI.
One feature I use constantly is mounting secrets without baking them into layers:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
Then build with:
docker build --secret id=npmrc,src=$HOME/.npmrc .
The secret is available during the build but never persists in any layer. Before BuildKit, people would COPY their .npmrc in, run npm install, then RUN rm .npmrc — but that secret still existed in an earlier layer. BuildKit solves this properly.
Putting It All Together: A Production Node.js Dockerfile
Here’s a complete example that combines everything — multi-stage, layer caching, non-root, minimal base:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/server.js .
COPY --from=builder /app/src ./src
USER nonroot
EXPOSE 3000
CMD ["server.js"]
Notice I’m not copying everything from the builder. I’m selectively copying only what the app needs to run. This keeps the final image as lean as possible and avoids accidentally including dev artifacts.
Once you’ve built it, push to ECR or whatever registry you’re using.
A Production Go Dockerfile
Go is where multi-stage builds really shine. Static binaries mean you can use scratch with zero compromise:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
USER 65534
EXPOSE 8080
ENTRYPOINT ["/server"]
A few things worth noting. The -ldflags="-s -w" strips debug info and symbol tables, shaving a few MB off the binary. I copy CA certificates from the builder because scratch has nothing — if your app makes HTTPS calls, it’ll fail without them. And USER 65534 is the nobody user, since scratch doesn’t have /etc/passwd to reference by name.
Cleaning Up Old Images
Multi-stage builds produce intermediate images that pile up. Your CI server will accumulate garbage fast. I’ve seen build machines run out of disk because nobody set up cleanup.
Make it a habit to remove old images regularly. In CI, I run docker system prune -f after every build. Locally, I do it weekly.
If you ever need to debug networking between containers during development, knowing how to get a container’s IP is handy too.
Common Mistakes I Still See
A few things that trip people up:
Not pinning base image tags. FROM node:latest means your build is non-reproducible. Pin to a specific version like node:20.11-alpine. Always.
Installing dev dependencies in production images. Use npm ci --only=production or pip install --no-dev. Your test framework doesn’t belong in prod.
Running apt-get update and apt-get install in separate layers. Combine them or you’ll get stale package lists from cache:
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag and the rm at the end keep the layer small.
Forgetting health checks. Add them:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD ["/bin/sh", "-c", "curl -f http://localhost:3000/health || exit 1"]
Though with distroless you won’t have curl, so you’ll need a compiled health check binary or rely on your orchestrator’s health checking.
Final Thoughts
Multi-stage builds aren’t optional anymore. They’re the baseline for anyone shipping containers to production. The combination of smaller images, faster builds, fewer vulnerabilities, and cleaner separation between build and runtime — there’s no downside.
Start with whatever language you’re using. Apply the pattern: fat build stage, minimal runtime stage. Add a .dockerignore. Run as non-root. Scan with Trivy. Enable BuildKit. These aren’t advanced techniques. They’re fundamentals.
That 2GB image I mentioned at the start? It was running in production for months before I fixed it. Every deploy was slow, every scan lit up like a Christmas tree with CVEs, and we were paying for oversized ECS tasks to accommodate the bloat. The fix took an afternoon. I wish I’d done it on day one. If you take one thing from this article, let it be this: the time you invest in your Dockerfile pays dividends on every single build, deploy, and security audit from that point forward. Don’t put it off like I did.