Skip to content
OpsAtlas

Multi-Stage Docker Builds: Smaller, Safer Images

By OpsAtlas · · 4 min read

Table of contents

A single-stage Dockerfile ships everything it needed to build the app: compilers, dev dependencies, source files, package caches and test fixtures. None of that is used at runtime, and all of it makes the image bigger and gives an attacker more tools to work with.

A multi-stage build splits the Dockerfile into stages. You build in one stage and copy only the result into a clean final stage. The final image is the only thing you ship; the build stages are thrown away.

How stages work

Every FROM starts a new stage. Name it with AS, then copy files out of it with COPY --from:

FROM golang:1.23 AS build
# … compile …

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app

Only the last stage becomes the image, unless you pick another with docker build --target <stage>. BuildKit, the default builder since Docker 23, also skips stages the target does not depend on. An unused docs or lint stage costs nothing in a normal build.

Node.js: compile TypeScript, ship only what runs

# syntax=docker/dockerfile:1
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
HEALTHCHECK CMD ["node", "dist/healthcheck.js"]
CMD ["node", "dist/server.js"]

What each stage is for:

  • deps installs every dependency, including TypeScript and the test tools. It only reruns when the lockfile changes.
  • build starts from deps, adds the source and compiles it to dist/.
  • prod-deps installs runtime dependencies only. It doesn’t depend on the source, so it stays cached through code changes.
  • The final stage gets node_modules from prod-deps and dist from build. No compiler, no src/, no dev dependencies, no npm cache. It runs as the image’s built-in node user.

Compare the result yourself with docker image ls myapp. The gap between this image and a single-stage node:22 build is usually several hundred megabytes, because the single-stage build carries the full Debian toolchain and every dev dependency.

Go: from compiler to a static image

Go can compile to a single static binary, so the runtime image needs almost nothing:

# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
  • CGO_ENABLED=0 makes the binary fully static. Without it, Go may link against the C library, and the binary fails to start on an image that has none.
  • distroless/static contains CA certificates, time zone data and a /etc/passwd, which a binary that calls HTTPS endpoints and logs timestamps needs. It has no shell and no package manager. The :nonroot tag runs as user 65532.
  • No shell means no HEALTHCHECK that calls curl. On Kubernetes that’s fine, because the kubelet ignores HEALTHCHECK and uses readiness and liveness probes instead. On plain Docker, add a healthcheck subcommand to the binary itself.

Faster rebuilds with cache mounts

Layer caching skips a step only when nothing before it changed. When the lockfile does change, npm ci or go mod download downloads everything again. A BuildKit cache mount keeps the package manager’s cache between builds without writing it to a layer:

RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /out/app ./cmd/app

The cache lives on the build machine, not in the image. In CI it is empty on a fresh runner unless you export it, for example with docker buildx build --cache-to type=gha on GitHub Actions.

Run tests in a stage, not in the image

A test stage builds on top of build, so it reuses the same layers:

FROM build AS test
RUN npm test
docker build --target test .   # CI: fails the build if tests fail
docker build -t myapp .        # release: the test stage is skipped

The production image never contains test files or test dependencies, and CI tests exactly the code that ships.

Pitfalls

  • ARG is scoped to a stage. An ARG declared before the first FROM can be used in FROM lines. To use its value inside a stage, declare it again there with ARG NAME, without a value.
  • COPY --from copies from the stage’s filesystem, not from your project folder. Use paths as they exist in that stage: /app/dist, not ./dist.
  • .dockerignore still matters. Every stage receives the same build context. A COPY . . in the build stage copies .git, .env and local node_modules unless you exclude them.
  • Check the final stage, not just the first. It is easy to harden the build stage and forget USER in the one that ships. The Dockerfile linter checks the final stage for a non-root user, exec-form CMD and a health check, and reports each stage’s base image if it is unpinned. Both Dockerfiles in this post pass with a score of 100. The Go image only gets an informational note about the missing HEALTHCHECK.

For the mistakes that leak secrets or bloat even a multi-stage image, see Dockerfile mistakes that leak secrets and bloat images. To run the result next to a database, the docker run to Compose converter turns your docker run commands into a compose.yaml.

Try it yourself

  • Dockerfile linter and fixer

    Lint a Dockerfile in your browser: find root users, unpinned images, leaked secrets and bloated layers, then apply safe fixes in one click.

    DockerBeta
  • docker run to Docker Compose converter

    Convert docker run commands into a clean compose.yaml: ports, volumes, env, networks, healthchecks and limits. Runs in your browser.

    Docker