CertGrid CertGrid
Hands-on Lab·Docker

Docker Multi-Stage Builds

Compilers, headers and package managers belong in the build, not in the thing you ship. One Dockerfile, two stages, and a measured result: a 253MB builder producing a 112kB runtime image.

Dockerfiles and Builds Guide 19 of 46 Intermediate

Tested on the versions above. Image sizes depend on the base image and toolchain versions at build time. Step numbers, digests and timings vary per run. Match the shape of the output, not the exact strings.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. The problem, stated as a number

    Compiling anything needs a toolchain. If the toolchain is in your final image, you ship it to every host that pulls the image, and every byte of it is attack surface for something that only ever needs to run one binary. A single-stage build of a C program carries gcc and musl-dev forever. The fix is to throw the builder away once it has produced the artifact.

    bash
    cat hello.c#include <stdio.h> int main(void){    printf("built in a builder stage\n");    return 0;}

    Expected resultA trivial program - the point is the packaging, not the code.

    Success conditionThe file exists. Any compiled language behaves the same way; C just makes the size difference obvious.

  2. Two stages in one Dockerfile

    Each FROM starts a new stage. Naming a stage with AS lets a later stage copy from it. The second stage here starts from scratch - a completely empty image, no shell, no libc, nothing - which is only viable because the binary is statically linked. COPY --from=build is the whole trick: it reaches into the previous stage's filesystem and takes only the file named.

    bash
    cat DockerfileFROM alpine:3.22 AS buildRUN apk add --no-cache gcc musl-devWORKDIR /srcCOPY hello.c .RUN gcc -static -O2 -o hello hello.c FROM scratch AS runtimeCOPY --from=build /src/hello /helloENTRYPOINT ["/hello"]

    Expected resultTwo FROM lines, each beginning a stage, and a COPY --from that crosses between them.

    Success conditionThe file has two FROM instructions. Without -static the binary would need libc at runtime and scratch would fail with a missing-loader error.

  3. Build and run it

    The build runs both stages, but only the last one becomes the tagged image. The builder stage is still in the build cache, which is why a rebuild is fast, but it is not part of what you ship.

    bash Example session
    docker build -t cg-multi:1 .#11 naming to docker.io/library/cg-multi:1 done#11 DONE 0.3sdocker run --rm cg-multi:1built in a builder stage

    Expected resultA normal build, then the program's output.

    Success conditionThe binary runs from an image with no operating system in it at all.

  4. Measure both stages

    --target builds a named stage and stops, which is how you inspect the intermediate. Tagging both makes the comparison concrete. This is the number that justifies the technique - the runtime image is roughly one two-thousandth the size of the builder, and the difference is entirely toolchain that the running program never needs.

    bash Example session
    docker build --target build -t cg-multi:builder .docker images --filter reference=cg-multi --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"REPOSITORY:TAG     SIZEcg-multi:1         112kBcg-multi:builder   253MB

    Expected resultTwo rows: the runtime image at kilobytes, the builder at hundreds of megabytes.

    Success conditionThe runtime image is dramatically smaller. If both are similar sizes, the final stage is probably inheriting from the builder rather than from a clean base.

  5. Push it and confirm the layer count

    A small image is not just a disk saving - it is what gets transferred on every pull, on every node, on every deploy. Pushing to a local registry shows exactly how few layers travel. Three layers here, and the whole thing is a rounding error on the wire.

    bash Example session
    docker tag cg-multi:1 localhost:5000/cg-multi:1docker push localhost:5000/cg-multi:1The push refers to repository [localhost:5000/cg-multi]44136fa355b3: Pushed33332d5fcf3a: Pushedbc3d926f773a: Pushed1: digest: sha256:e2afd891162845f0908563e47ab6194b57d3ed8bb7ea7eacd83d6be5b6680720 size: 855

    Expected resultThree layers pushed and a manifest digest.

    Success conditionA digest is returned. Compare with pushing the builder stage - it would move hundreds of megabytes for the same program.

  6. Clean up

    Remove the images you tagged. The build cache still holds the builder stage, so an immediate rebuild stays fast.

    bash
    # removes only the images this guide createddocker rmi cg-multi:1 cg-multi:builder localhost:5000/cg-multi:1Untagged: cg-multi:1Untagged: cg-multi:builderDeleted: sha256:2ecd09948ebd7303294fe200e2a3e91d22bada99628b8ee8e4e0607f906ed10dUntagged: localhost:5000/cg-multi:1Deleted: sha256:9bc07684f095417540cc470ac05604508ca8380be19b5fa75b712bf5cc723b5b

    Expected resultUntagged and deleted lines.

    Success conditiondocker images --filter reference=cg-multi returns nothing.

Troubleshooting

Official sources