5 multi-stage dockerfile build patterns for production (2026)

By Kate Baker
5 multi-stage dockerfile build patterns for production (2026)

Most Dockerfiles start life as a copy of the "getting started" example: one FROM, one COPY . ., one RUN npm install, done. That works until the image is 1.4 GB, every CI run reinstalls dependencies from scratch, and someone notices the .npmrc with a registry token baked into layer three.

Multi-stage builds fix most of that. The idea is simple: you use several FROM instructions in one Dockerfile, do the heavy lifting (compilers, dev dependencies, build tools) in early stages, and copy only the finished artifacts into a small final stage. The final image never contains what it does not need to run.

This post skips the "what is Docker" part and goes straight to five patterns that hold up in production. Each one solves a specific problem, and you can mix them in the same file.

Before you start: the baseline every dockerfile build needs

A few settings apply to every pattern below, so it is worth getting them right once.

Enable BuildKit syntax features. BuildKit is the default builder in current Docker Engine and Docker Desktop, but the newest Dockerfile features (cache mounts, secret mounts, heredocs) are pinned through the syntax directive. Put this on line one:

# syntax=docker/dockerfile:1

This tells BuildKit to use the latest stable 1.x Dockerfile frontend, so you get new features without upgrading the Docker daemon.

Write a real .dockerignore. The build context is everything Docker sends to the builder. Without a .dockerignore, a COPY . . drags in node_modules, .git, local .env files, and test fixtures. That slows the build and busts the cache every time a file you do not care about changes.

.git
node_modules
dist
coverage
*.log
.env
.env.*
Dockerfile*
docker-compose*.yml

Order instructions from least to most frequently changed. Docker caches each instruction as a layer. When a layer changes, every layer after it is rebuilt. Copy dependency manifests first, install, then copy source code. You will see this ordering in every example below.

Pattern 1: separate dependencies, build, and runtime (Node.js)

This is the pattern most teams need first. A Node.js app typically needs dev dependencies (TypeScript, bundlers, test runners) to build, but only production dependencies to run.

# syntax=docker/dockerfile:1

FROM node:24-slim AS base
WORKDIR /app

# Stage 1: all dependencies, including dev, for building
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

# Stage 2: compile the app
FROM deps AS build
COPY . .
RUN npm run build

# Stage 3: production dependencies only
FROM base AS prod-deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Stage 4: the image that actually ships
FROM node:24-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

What this buys you:

  • The runtime stage has no TypeScript, no bundler, no test framework. On a typical API, that alone removes a few hundred megabytes.
  • prod-deps and build are independent, so BuildKit runs them in parallel.
  • Dependency layers are cached until package-lock.json changes. Editing a source file only reruns COPY . . and npm run build.
  • USER node runs the process as the unprivileged user the official Node image already provides.
Use CMD ["node", ...] rather than CMD ["npm", "start"]. When npm is PID 1 it does not always forward SIGTERM to your app cleanly, which turns graceful shutdowns into hard kills during deploys.

Trade-off:node:24-slim is Debian-based and includes a shell and package manager. That is useful for debugging but adds attack surface. If you want smaller, Alpine variants exist, but they use musl instead of glibc, which occasionally breaks native modules. Test before you switch.

Pattern 2: compile to a static binary, ship almost nothing (Go)

Compiled languages get the most dramatic results from a multi-stage dockerfile build. The final image only needs the binary and a few supporting files.

# syntax=docker/dockerfile:1

FROM golang:1.26 AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]

Key details:

  • CGO_ENABLED=0 produces a statically linked binary that does not depend on the system C library. Without it, the binary may fail to start on a minimal base image.
  • -trimpath -ldflags="-s -w" strips local file paths and debug symbols, which shrinks the binary and makes builds more reproducible.
  • Distroless static contains CA certificates, timezone data, and /etc/passwd entries, but no shell and no package manager. Final images are often in the 10–25 MB range depending on your binary.

Why not FROM scratch? You can, but then you have to copy CA certificates and timezone data yourself, and you have no non-root user defined. Distroless handles those details for you.

Trade-off: no shell means no docker exec -it ... sh for debugging. For live debugging, use docker debug (in recent Docker Desktop versions) or run a debug variant of the image (distroless publishes :debug tags with a busybox shell) in a non-production environment.

Pattern 3: build a virtualenv, copy it across (Python)

Python does not compile to a single binary, but the same principle applies. Many Python packages need compilers and headers (gcc, libpq-dev) to install, and none of that belongs in production.

The trick is to install everything into a virtual environment in the builder, then copy the whole venv to the runtime stage.

# syntax=docker/dockerfile:1

FROM python:3.13-slim AS build
RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential libpq-dev \
 && rm -rf /var/lib/apt/lists/*

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.13-slim AS runtime
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/* \
 && useradd --create-home --uid 1000 app

COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app
COPY --chown=app:app . .
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "myproject.wsgi:application"]

Notice the split between libpq-dev (headers, build stage only) and libpq5 (the shared library, runtime stage). That is the general rule: build dependencies stay in the builder, runtime libraries go in the final stage.

A few gotchas worth knowing:

  • Both stages must use the same Python version and base distro. A venv contains absolute paths and compiled extensions tied to the interpreter. Copying a venv built on python:3.13-slim into python:3.12-alpine will break.
  • PYTHONUNBUFFERED=1 makes logs appear immediately in docker logs instead of being held in a buffer.
  • If you use uv or Poetry, the pattern is identical. Point the tool at /opt/venv in the builder and copy the directory.

Pattern 4: one Dockerfile, multiple targets (test, dev, prod)

Teams often end up with Dockerfile, Dockerfile.dev, and Dockerfile.test that slowly drift apart. Multi-stage builds let you keep one file and select a stage with --target.

# syntax=docker/dockerfile:1

FROM node:24-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./

FROM base AS deps
RUN npm ci

# Local development: full deps, source mounted at runtime
FROM deps AS dev
ENV NODE_ENV=development
CMD ["npm", "run", "dev"]

# CI: run tests and lint inside the image
FROM deps AS test
COPY . .
RUN npm run lint && npm test

FROM deps AS build
COPY . .
RUN npm run build

FROM node:24-slim AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Now each environment picks its stage:

# CI: fails the build if tests fail
docker build --target test .

# Production image
docker build --target prod -t myapp:1.4.2 .

And in compose.yaml for local work:

services:
  app:
    build:
      context: .
      target: dev
    volumes:
      - ./src:/app/src
    ports:
      - "3000:3000"

Run it with docker compose up --build.

Two things to understand about how BuildKit handles targets:

  1. It only builds the stages the target depends on.--target prod skips dev and test entirely. That is great for speed, but it means your production build does not run tests unless you build the test target separately in CI.
  2. Stage order matters for the default. If you run docker build . without --target, the last stage in the file is built. Keep prod last so the default is always the production image.
If you want production builds to be impossible without passing tests, add COPY --from=test /app/package.json /tmp/tests-passed to the prod stage. The dependency forces BuildKit to build test first. It costs build time, so most teams prefer running the test target as a separate CI step.

Pattern 5: cache mounts and build secrets

The previous patterns reduce image size. This one speeds up builds and closes a common security hole.

Cache mounts for package managers

Layer caching is all-or-nothing: change one line in package-lock.json and npm ci downloads every package again. A cache mount keeps the package manager's download cache on the builder between builds, without putting it in the image.

RUN --mount=type=cache,target=/root/.npm \
    npm ci

The same idea works for other ecosystems:

# Go modules and build cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /out/app ./cmd/server

# pip
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# apt (remove the docker-clean hook so packages are kept)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean \
 && apt-get update && apt-get install -y --no-install-recommends libpq5

When you use a pip cache mount, drop --no-cache-dir, otherwise pip ignores the cache you just mounted.

Caveat for CI: cache mounts live on the builder. On ephemeral CI runners, the builder is thrown away after every job, so cache mounts help less there. For CI, layer caching through an external cache backend (for example docker buildx build --cache-to type=gha,mode=max --cache-from type=gha on GitHub Actions, or a registry cache) matters more.

Build secrets instead of ARG or COPY

Private registries and private Git dependencies need credentials at build time. The two common mistakes:

  • COPY .npmrc . then RUN rm .npmrc — the file still exists in the earlier layer and anyone who pulls the image can extract it.
  • ARG NPM_TOKEN — build args are recorded in the image history and visible with docker history.

Multi-stage builds help, because secrets in a builder stage never reach the final image. But the builder image can still be cached, pushed, or inspected. The correct fix is a secret mount, which exposes the file only for the duration of one RUN:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

Pass it at build time:

docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

For SSH-based Git dependencies, there is an equivalent --mount=type=ssh combined with docker build --ssh default.

Putting it together: a checklist for production images

Once you combine these patterns, a solid production dockerfile build tends to look the same regardless of language:

  1. # syntax=docker/dockerfile:1 on the first line.
  2. A .dockerignore that excludes VCS data, local deps, and env files.
  3. Dependency manifests copied and installed before source code.
  4. Cache mounts on package manager steps.
  5. Credentials through --mount=type=secret, never ARG or COPY.
  6. A final stage built from a slim or distroless base with only runtime artifacts.
  7. A non-root USER.
  8. Exec-form CMD or ENTRYPOINT so signals reach your process.
  9. Pinned base image versions (at least the major version, ideally a digest for strict reproducibility).
  10. A separate test target that CI builds before pushing the prod image.

To check that the result is what you expected, inspect the layers:

docker image ls myapp
docker history myapp:latest

If docker history shows a large layer you did not expect, it is usually a COPY . . pulling in something the .dockerignore missed.

Where the image goes next

A well-built image is only half the job. You still need somewhere to run it, a way to rebuild it on every push, and a database it can talk to.

If you do not want to manage that infrastructure yourself, Granite.so builds and deploys directly from your Git repository or a prebuilt Docker image. Push to your branch and it rebuilds using your Dockerfile, so every pattern above (targets aside, keep prod as the last stage) works unchanged. You can attach a managed database, set environment variables in the dashboard instead of baking them into the image, and let automatic scaling handle traffic spikes.

The multi-stage work pays off here too: smaller images mean faster pulls, which means faster deploys and quicker scale-out when new instances start.

Wrapping up

Multi-stage builds are not an advanced trick. They are the default way to write a production Dockerfile in 2026. Start with Pattern 1 or 2 for your language, add cache mounts once build times start to hurt, and move any credentials to secret mounts today if they are currently in ARG or COPY.

If you want to skip the server setup and get your image running, you can try Granite.so on the Hobby plan: $9 per month, with $15 in monthly usage credits included.

Keep reading

More posts tagged “Tutorials”.
Be first in line for updates
and special pricing
Get early access to new features and exclusive discounts delivered straight to your inbox