Your image carries the entire toolchain that built it into production. A second FROM line leaves it behind.



Most container images are large for one boring reason: the build tools sneak into production. The compiler, the package manager, the development dependencies, and often the source code itself all ship in the final image, where none of them run.

The cost of that is measurable. A real ASP.NET service built in a single stage came to 848 megabytes; the same app split into a build stage and a minimal runtime stage was 218 megabytes, and with a distroless runtime base it dropped to 110, nearly eight times smaller (Siromin, 2025). For compiled languages, switching to a multi-stage build with a distroless or scratch base routinely gives a 90 per cent reduction and final images under 20 megabytes (DevOps Training Institute, 2025).

The technique is the multi-stage build, and it is not an advanced trick reserved for platform teams, it is the baseline for any production image (Andrew Baker, 2026). This post is the practical version: how to structure the stages, choose the final base, order layers so the cache actually helps, and keep your build secrets and the toolchain out of the image that ships. Real Dockerfiles, not theory.

Build in one stage, ship from another

A multi-stage build puts more than one FROM in a single Dockerfile. An early stage compiles the application with the full toolchain, and a final stage starts from a minimal base and copies in only the finished artifact. Only the final stage is pushed to the registry; the builder and all its compilers are discarded once the build completes (DEV, 2026).

# syntax=docker/dockerfile:1
# ---- build stage: full Go toolchain ----
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

# ---- final stage: just the binary ----
FROM gcr.io/distroless/static
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

The line that does the work is COPY --from=build, which pulls only the compiled binary out of the builder. The final image is a distroless base plus one file, with no Go compiler, no shell, and no source, because a distroless image contains only your application and its runtime dependencies (DEV, 2026).

The trade-off is that the Dockerfile is longer, and you are now describing two environments rather than one. That complexity is real but small, and it is almost always worth it. The one case where it is not is something like serving a handful of static files, where there is no real build step to separate out.

The same .NET service was 848 megabytes built in a single stage, 218 split into build and runtime, and 110 with a distroless final base (Siromin, 2025). For compiled languages the reduction is routinely 90 per cent or more (DevOps Training Institute, 2025).

Order your layers so the cache actually helps

Docker builds in layers, and each instruction is cached until something it depends on changes. The single most common mistake is copying your whole source tree before installing dependencies, which means any code change throws away the dependency cache and reinstalls everything. The order is the fix: copy the lockfile and install first, then copy the source.

# Wrong: any source change reinstalls every dependency
COPY . .
RUN npm ci

# Right: deps are cached until package-lock.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Because the dependency install now sits above the source copy, it only re-runs when the lockfile changes, not on every code edit. In practice this is the difference between a 20-minute rebuild on every change and a 20-second one (DevOps Training Institute, 2025).

The trade-off is almost none, beyond the discipline of ordering instructions from least to most frequently changing. The one thing to add is a .dockerignore, so that your local clutter, the source-control directory, logs, and any local environment files never enter the build context in the first place (MeshWorld, 2026).

Cache dependencies across builds with BuildKit mounts

Layer ordering caches dependencies between builds only when the lockfile is unchanged. A cache mount goes further, keeping the package manager's own cache directory around across builds without ever baking it into a layer. It has been the default builder since Docker Engine 23.0 and is one of the highest-leverage changes available for CI pipelines that install hundreds of packages from scratch (Andrew Baker, 2026).

# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
# Cache ~/.npm between builds; it never enters a layer
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

The --mount=type=cache directive gives that one command a persistent cache directory that survives between builds, which can reduce dependency installation time by ten times or more while keeping the cache out of the final image entirely (OneUptime, 2026).

The trade-off is that the cache lives with the builder, so it is local by default and an ephemeral CI runner starts cold. The fix in CI is to export the build cache to a registry or a persistent location, so even fresh runners get a warm cache rather than rebuilding from nothing.

Keep build secrets out of the image

Builds often need a credential, a token for a private package registry or an SSH key for a private repository. The dangerous way is to pass it through a build argument or copy it into a stage and delete it later, because both leave it permanently in the image history, retrievable by anyone who inspects the image (Docker Docs, 2026). A secret mount makes the credential available for a single command and never writes it to a layer.

# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
# Mounted for this RUN only; the token never lands in a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

# Build it with:
# docker build --secret id=npmrc,src=$HOME/.npmrc -t app .

The token is mounted only for the duration of that npm ci, then it is gone, and it bypasses the layer cache entirely (OneUptime, 2026). This matters more than it sounds, because base images and careless builds leak credentials constantly.

The trade-off is essentially none, beyond remembering to verify. After a build, run a quick check of the image history to confirm nothing sensitive landed in a layer, and treat any secret that does as already compromised and rotate it.

Deleting a secret file in a later instruction does not remove it, because the earlier layer still holds it, and GitGuardian estimated that 7 per cent of public Docker Hub images contained at least one secret (GitGuardian, 2025). A secret mount is the difference between a credential that exists during the build and one that ships forever.

Make the final image safe by default: minimal and non-root

The final stage's base image is the single biggest driver of both size and attack surface. Ubuntu commonly exceeds 77 megabytes, Alpine sits under 7, and distroless is smaller still, so the wrong base can inflate an image by an order of magnitude before you write a line of application code (Andrew Baker, 2026). On top of a minimal base, the container should run as a non-root user.

# ---- final stage: minimal and non-root ----
FROM node:20-alpine AS final
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
# Run as an unprivileged user, not root
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The USER node line drops the container from root to an unprivileged account, so a compromise inside it has far less to work with. Pair that with a vulnerability scan and a generated software bill of materials in your pipeline, so you know exactly what is in the image you are shipping (Andrew Baker, 2026).

The trade-off lives in the base choice. Alpine uses musl rather than glibc and can occasionally trip up packages that assume glibc, so test it before trusting it, and distroless is the most secure option but gives you no shell to debug inside the container. Both are usually the right call for production, as long as you go in knowing the cost.

The part worth sitting with

So open the Dockerfile for your most important service and read its last FROM line, because that line is your production image. If it is the same heavy base you built on, you are shipping the compiler, the package manager, and probably the source to every node that runs your app, and paying for it on every pull, every scan, and every cold start. The fix is not exotic. It is a second FROM, a minimal final base, layers ordered so the cache works, and your secrets mounted rather than copied. The same service that needs 848 megabytes the careless way fits in 110 the careful way, runs as a non-root user, and gives a vulnerability scanner almost nothing to find. Multi-stage builds are not an optimisation you graduate to. They are the difference between shipping your application and shipping your whole workshop along with it.

Author note

I am Manjunaathaa, an Associate DevOps Engineer at Frigga Cloud Labs. I work across AWS, GCP, and Azure daily, with GitHub Actions as my deployment backbone. My focus is Proactive Resilience: the image you ship is your attack surface and your cloud bill at the same time, so I treat every megabyte in the final stage as something that has to justify being there. Every practice in this post is something I actually run in production, not something I read about. I wrote this because shipping the build toolchain to production is the most common waste I see, and it is free to fix. The thing I keep coming back to is that a smaller image is not just cheaper, it is safer, because a scanner cannot flag a package you never shipped. I build with the full toolchain, copy out only the binary, run it as a non-root user on a distroless base, and keep my registry tokens in a secret mount where they never touch a layer. Build with everything, ship with almost nothing. Let's connect on LinkedIn → Manjunaathaa.

Post a Comment

Previous Post Next Post