CoderBlog
Hosting

Docker Multi-Stage Builds in 2026: 14 Services, 18MB Average

14 production .NET 9 AOT, Go, and Rust services: slim distroless images, BuildKit cache patterns, and security defaults that survive an audit.

I shipped my first Dockerfile in 2014. It was a 1.4GB Ubuntu image that ran a Python app, took 14 minutes to build, and another 6 minutes to push to a private registry. I thought that was fine because everybody else's images were the same size. Twelve years later, my average production image is 18MB, my average build time is 38 seconds when the cache is warm, and my CI bill dropped 47% in the last quarter because I finally stopped pretending BuildKit was "optional."

This post is not a "what is Docker" tutorial. If you need that, you are reading the wrong site. This is the playbook I wish I had in 2021 when I started running .NET, Go, and Rust side by side in the same production cluster. Some of it is obvious in hindsight. Some of it took me a year of debugging to figure out. All of it is running in production today across 14 services.

Docker multi-stage builds: container layers and a slim final image

Fig. 01 — A working engineer's view of a multi-stage build: 14 services, an average 18MB final image, and a CI pipeline that no longer takes lunch breaks.

The Numbers First, Because the Numbers Matter

Before I get into the patterns, here is the actual data I pulled from the registry this morning. Fourteen production services, all built with multi-stage Dockerfiles, all running in 2026:

Runtime Base image (final stage) Image size Cold build Warm cache build
.NET 9 AOT minimal API mcr.microsoft.com/dotnet/runtime-deps:9.0 14.2 MB 92s 11s
.NET 9 regular (no AOT) mcr.microsoft.com/dotnet/aspnet:9.0 108 MB 71s 9s
Go 1.23 single binary gcr.io/distroless/static-debian12:nonroot 12.8 MB 44s 6s
Rust 1.83 (axum) gcr.io/distroless/cc-debian12:nonroot 21.4 MB 312s 28s
Node 22 (Fastify) gcr.io/distroless/nodejs22-debian12:nonroot 96 MB 68s 14s
Python 3.12 (FastAPI) gcr.io/distroless/python3.12-debian12:nonroot 112 MB 51s 8s

The cold build column assumes an empty BuildKit cache. The warm cache column assumes only the source layer changed — the dep layer, base image, and intermediate stages are all cached. Those numbers are the real reason I stopped arguing about whether BuildKit was worth the setup cost. On a $0.08/minute CI runner, a Rust cold build used to cost me 41 cents per PR. After BuildKit with proper layer pinning, that dropped to 3.7 cents. Multiply by 200 PRs a month across the Rust service, and I am saving $74 a month on CI alone for one service.

A few things to call out before you ask in the comments. First, the .NET 9 AOT row is the most interesting. That 14.2MB image is statically linked, runs as a single binary on the runtime-deps base, and contains zero managed runtime. The cold build time of 92 seconds is the cost of running the AOT compiler, which is heavier than a normal .NET build. Once you have cached the AOT outputs, the warm build is back down to 11 seconds. The tradeoff is well worth it for any service where you can stick to AOT-compatible APIs (no reflection-heavy libraries, no dynamic code generation).

Second, look at the Go row. Twelve point eight megabytes. That is the entire service, including the static assets and a 4MB dictionary file for our search endpoint. There is no shell, no package manager, no /usr/bin directory. Just the binary and a CA cert bundle. It runs as UID 65532, which is the nonroot user that Google's distroless images ship with by default.

Third, the Rust row is the only one that genuinely hurts. Three hundred and twelve seconds for a cold build is five minutes. That is the cost of compiling tokio, axum, serde, and sqlx from scratch. The mitigation is the same as it has always been: cache the target/ directory between builds, which we will get to in the BuildKit section.

The Multi-Stage Pattern, in Three Lines

The actual mechanics of multi-stage builds are not complicated. You write a Dockerfile with multiple FROM statements, and you copy artifacts from earlier stages into later ones with --from=. That is it. The reason it took the industry a decade to adopt this is the same reason most of our bad engineering decisions happen: the easy path was easier, and we all had a deadline on Friday.

Here is the smallest useful .NET 9 AOT multi-stage Dockerfile I can write that is also production-grade:

# syntax=docker/dockerfile:1.9
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY NuGet.config Directory.Build.props ./
COPY src/MyService/MyService.csproj src/MyService/
RUN dotnet restore src/MyService/MyService.csproj
COPY src/MyService/ src/MyService/
RUN dotnet publish src/MyService/MyService.csproj \
    -c $BUILD_CONFIGURATION \
    -o /app/publish \
    /p:PublishAot=true \
    /p:StripSymbols=true \
    /p:InvariantGlobalization=true

FROM mcr.microsoft.com/dotnet/runtime-deps:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
USER 1000:1000
ENTRYPOINT ["./MyService"]

That is 17 lines. It produces a 14MB image. The previous version of this Dockerfile, before I added AOT, was 12 lines and produced a 108MB image. The extra five lines are the PublishAot=true, StripSymbols=true, and InvariantGlobalization=true MSBuild properties. The InvariantGlobalization=true is the trick most people miss: it strips the ICU data tables from the runtime, which on its own saves around 28MB. If your service does not need to format dates, parse currencies, or do culture-aware string comparisons, you almost certainly want this.

The Go equivalent is even shorter:

# syntax=docker/dockerfile:1.9
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w -extldflags '-static'" \
    -o /out/service ./cmd/service

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

CGO_ENABLED=0 is the line that gets people. Without it, Go will dynamically link against glibc, which means your final image needs a base OS to provide glibc, which means your image is 80MB instead of 12MB. The nonroot user on the distroless image is UID 65532. The -ldflags="-s -w" strips the symbol table and DWARF debug info, which on a typical axum service saves another 4MB.

BuildKit Caching: The Part That Actually Saves Money

Multi-stage builds are the prerequisite. BuildKit is the payoff. Without BuildKit caching, every CI run rebuilds every layer from scratch. With it, you can pin the cache to specific layers, share it across runners, and reuse it across branches.

The single most important BuildKit feature I adopted in 2025 is the --mount=type=cache directive. It lets you tell BuildKit to cache a directory between builds without including it in the final image. This is what the Go Dockerfile should actually look like:

# syntax=docker/dockerfile:1.9
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
      -ldflags="-s -w -extldflags '-static'" \
      -o /out/service ./cmd/service

The --mount=type=cache,target=/go/pkg/mod line tells BuildKit to keep the Go module cache between builds. The --mount=type=cache,target=/root/.cache/go-build keeps the Go build cache. Neither directory ends up in the final image. Both of them survive between CI runs if you use the --cache-from and --cache-to flags correctly.

The Rust version is the same idea, but the cache targets are different:

# syntax=docker/dockerfile:1.9
FROM rust:1.83-bookworm AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    mkdir -p src/bin && echo "fn main() {}" > src/bin/main.rs && \
    cargo build --release --bin myservice && \
    rm -rf src
COPY src/ src/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/src/target \
    cargo build --release --bin myservice

The trick of writing a stub main.rs, building once to populate the dep cache, then copying in the real source is the standard workaround for Rust's lack of a cargo restore equivalent. It is ugly. It works. With this pattern, the Rust warm cache build drops from 312 seconds to 28 seconds. Without it, you pay the full cold build cost on every PR.

For the CI side, the buildx command looks like this:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --cache-from type=registry,ref=ghcr.io/myorg/myservice:cache \
  --cache-to type=registry,ref=ghcr.io/myorg/myservice:cache,mode=max \
  --tag ghcr.io/myorg/myservice:$GITHUB_SHA \
  --push \
  .

The mode=max is the bit I missed for the first six months. By default, BuildKit only caches layers that are explicitly exported in the final image. mode=max caches all intermediate layers, which is what you actually want for the --mount=type=cache targets. Without mode=max, the build cache is useless because the cached layers are the ones that are deterministic, not the ones that are slow to rebuild.

BuildKit layer cache: cold build vs warm cache, time and bytes per stage

Fig. 02 — The same Rust build, cold cache (left) versus warm cache (right). The dep stage is the slow part. Cache it once, reuse it forever.

Multi-Architecture Without Losing Your Mind

I run services on both amd64 (Intel VPS) and arm64 (Oracle Graviton, which is roughly 30% cheaper per core). Building for both at once is the part that made me want to quit Docker in 2022. BuildKit finally made it boring.

The first thing to understand is that linux/amd64 and linux/arm64 are not the same image with different tags. They are two separate images, each with their own layer hashes, both stored in the same manifest under a single tag. The registry serves the right one based on the client's platform.

The buildx command is the same as before, just with --platform linux/amd64,linux/arm64. The Dockerfile needs to not assume a specific architecture. The two ways this breaks in practice:

  1. You have a hardcoded apt-get install that pulls a package only available on amd64. Fix: use the multiarch package repo and let dpkg --print-architecture figure it out.
  2. You are calling a binary that does not exist on arm64. Fix: don't do that, or vendor the right binary for each platform.

For the distroless images, this is a non-issue. Google's distroless manifests already include both architectures. For the .NET 9 AOT image, the same is true. For the Go binary, the build runs inside a QEMU-emulated runner on whichever architecture is not native, which is slower but works. For Rust with cross-compilation, you can use the rust-cross image or just set CROSS_CONTAINER_IN_CONTAINER=true and let buildx handle QEMU for you.

The QEMU emulation adds about 35% to the cold build time. It is the part I complain about most. There is no way around it without buying a native arm64 CI runner, which I have not yet done because the cost-benefit math on a small team does not pencil out.

Security: The Part Everyone Skips Until They Get Audited

I am not going to give you a security checklist. There are a hundred of those on the internet, and most of them are written by people who have never seen a Dockerfile in production. I am going to tell you the three things I have actually changed in the last 12 months because of real audit findings or real incidents.

Run as non-root by default. Every single production image in my registry now sets USER to a non-zero UID. The .NET 9 image sets USER 1000:1000. The Go and Rust distroless images set USER nonroot, which resolves to UID 65532. The only image I still have running as root is a legacy Jenkins controller that I have not had time to migrate, and that image is on a separate isolated network. If you are writing a new Dockerfile in 2026 and it does not set USER, you are doing it wrong. There is no excuse.

Drop capabilities and set read_only: true. In the docker-compose file, the relevant block looks like this:

services:
  myservice:
    image: ghcr.io/myorg/myservice:latest
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp:size=64m

read_only: true makes the root filesystem immutable. no-new-privileges prevents setuid binaries from gaining privileges. cap_drop: ALL removes every Linux capability the container would otherwise inherit. The tmpfs line is there because some libraries need to write to /tmp at runtime, and a 64MB tmpfs is enough for almost any service. If your service writes more than 64MB to /tmp, you have a bug, and this configuration will surface it.

Use distroless, not Alpine. Alpine used to be the default "small image" choice. It is still smaller than Debian for some workloads. The problem is musl libc. Roughly 1 in 4 of the Node and Python libraries you depend on have a glibc-specific behavior that musl does not implement. The bugs are subtle, they only show up in production under load, and they are not fun to debug. Distroless is debian-based, runs the same libc as every other Debian image, and is the same size as Alpine for statically linked Go binaries. The size argument is gone. The musl argument is the reason I switched.

The distroless images do not include a shell. That means you cannot docker exec into a running container and run bash. This is the complaint I hear most often. The response is: you should not be execing into production containers anyway. If you need to debug, attach a debugger, read the logs, or use ephemeral debug containers with kubectl debug or docker debug. The lack of a shell is a feature, not a bug.

Distroless image: final layer, no shell, nonroot user, signed SBOM

Fig. 03 — What a production-grade distroless image actually looks like: one binary, one user, one entrypoint, and a signed SBOM attached to the manifest.

The Patterns I Don't Use Anymore

A few things that I used to do that I have since stopped, because they either did not work as advertised or caused real problems:

docker-squash. Used to be a popular tool to merge all layers into one. It makes images smaller on disk but does not change what gets pulled over the network. Worse, it breaks layer caching in ways that are hard to debug. BuildKit's --cache-to type=registry,mode=max is the right answer in 2026. Squash is dead.

ADD for remote URLs. The ADD instruction can fetch URLs and extract archives. It looks convenient. It is also a security antipattern because the URL is fetched at build time and there is no integrity check. Use RUN curl --fail --silent --show-error --location -o /tmp/file.tar.gz https://... && tar -xzf /tmp/file.tar.gz instead. Or even better, vendor the file into the repo and use COPY. The CDN will thank you.

Multi-stage FROM scratch for Go. I did this for years. It is the smallest possible image, around 6MB. The problem is that you cannot get a CA cert bundle in there, so your HTTPS calls fail mysteriously on production. If you want a scratch image, you have to copy /etc/ssl/certs/ca-certificates.crt from a Debian build stage into the scratch stage, and you have to remember to do it every time the base image updates. Distroless solves this for you. The 6MB savings is not worth the operational pain.

Building inside the application container. A surprising number of teams still run docker build inside a Docker-in-Docker container, mount the Docker socket, and pretend this is CI. The Docker socket is root-equivalent. Any compromise of the build container gives the attacker root on the host. Use Kaniko, BuildKit in standalone mode, or one of the proper build services. The Docker socket in CI is the kind of pattern that works fine until the day it does not, and on that day you will be reading logs for a week.

Copying node_modules from the host. With Node 22 and pnpm, the correct pattern is to install deps inside the build stage, not to copy them from the host. The OS, the libc version, and the Node version all have to match between the host and the container, and the only reliable way to guarantee that is to not rely on the host at all. The npm ci step inside the Dockerfile is the right answer. It also makes the build reproducible, which COPY node_modules is not.

Migration: From Fat Image to Multi-Stage in a Week

If you have a legacy service on a 1GB+ image, the migration is not as scary as it sounds. Here is the playbook I used for the 14 services I have rewritten over the last 18 months.

Day 1-2: Inventory. Run docker history on every production image. Find the layers that are pulling in the most bytes. Most of the time, it is apt-get install pulling in curl, wget, git, vim, and other things nobody needs at runtime. Half of those can be moved to the build stage and discarded.

Day 3-4: Build the new Dockerfile in a branch. Write the multi-stage version. Push it to a sandbox registry. Do not deploy it. The point is to measure the new image size and the new build time against the old one. Use the table format I showed above. If the new image is not at least 50% smaller, you have not done the migration right.

Day 5: Run the new image in staging. Use the same docker-compose.yml, just point the image tag at the new build. Watch the logs for 24 hours. The most common failure mode is a missing shared library that was implicitly present in the fat image. Fix the Dockerfile, rebuild, repeat.

Day 6: Production canary. Deploy the new image to 5% of traffic. The canary should run for at least 4 hours. Watch the error rate, the p99 latency, and the memory usage. AOT and distroless can both expose memory leaks that were hidden by the larger runtime, so this is the part where you find them.

Day 7: Full cutover. Roll the new image to 100% of traffic. Keep the old image tag in the registry for 30 days as a rollback target. After 30 days, delete it.

I have done this migration seven times. The fastest was a Go service that went from 980MB to 12MB in 4 hours. The slowest was a Python service with a custom C extension that took me three weeks because the extension had to be cross-compiled for arm64. On average, expect a week. Expect a 70-90% size reduction. Expect a 30-50% reduction in CI build time once BuildKit caching is wired up.

What This Article Is Not

I am not going to tell you that every image should be 12MB. I am not going to tell you that distroless is always the right answer. I am not going to tell you to AOT everything. Those are decisions that depend on the workload, the team, the runtime requirements, and the operational constraints you have. The 18MB average across my 14 services is a number that came out of two years of iteration, not a target you should aim at on day one.

What I will tell you is this. If your Dockerfiles are still on a single stage, if they still apt-get install a shell and a text editor, if they still run as root, if they still take 10 minutes to build from scratch on every PR — you are spending money you do not need to spend, and you are one CVE away from an incident. The patterns in this post are not new. BuildKit has been GA for over four years. Distroless has been GA for three. The reason I wrote this in 2026 is that I still see production Dockerfiles in 2026 that look like they were written in 2018.

The first step is to run docker history on your current production image. Look at the output. If the top three layers are not your application binary, your application config, and a CA cert bundle, you have work to do.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.